feat(matrix,stoat): run without a domain, on the LAN or a VPN

Both apps demanded a domain and Traefik. That was over-constrained: LibrePortal
ships WireGuard, Headscale and private ports, so LAN and VPN-only is a
first-class deployment here, and Rocket.Chat and Mattermost already prove chat
apps work fine on http://<lan-ip>:<port>.

The gate on Matrix rested on a mistake of mine: server_name being permanent.
server_name and public_baseurl are independent — the identity can be a domain
you own with no DNS behind it while clients reach the server on a LAN address,
so federation can be switched on later by adding DNS and TLS, with no rebuild
and no lost history. CFG_MATRIX_SERVER_NAME now exposes exactly that, and the
install warns when it falls back to the machine's IP.

What is genuinely lost without a domain is stated where it belongs, at install:
Matrix cannot federate and Element's mobile apps want HTTPS; Stoat cannot do
camera or microphone, because browsers gate getUserMedia on a secure context
and a VPN does not change that, the check being on the URL scheme.

Both now derive their URL from the port that was actually allocated. Since ports
are only assigned during compose-up, each writes a best guess before start and
corrects it afterwards, restarting only when the value really changed.

Three bugs found while proving it works end to end:

- The Synapse image writes /data as its UID/GID env, default 991, which under
  rootless is a host sub-UID owning nothing — so the generated signing key could
  not be moved by the install user. Both the generate container and the service
  now run as the same identity USER_TAG resolves to.

- Element's config.json is bind-mounted as a file, and docker silently creates a
  DIRECTORY when the source is missing. An early return left exactly that
  landmine, which then broke every later run. It is written first now, and a
  stale directory is cleared.

- A successful admin registration was reported as an error: checkSuccess read $?
  after an intervening [[ ]] test rather than the command's own status.

Verified with no domain and no Traefik installed: Synapse answers
/_matrix/client/versions and /health on http://<ip>:<port>, admin login returns
a token, and Element is configured against the corrected base_url.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 20:51:16 +01:00
parent c7df07ffc2
commit 4eb5fa6434
9 changed files with 374 additions and 110 deletions

View File

@ -24,6 +24,15 @@ services:
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
# These are not redundant with `user:` above. The image's entrypoint
# checks whether it is root and, if so, su-execs down to UID:GID —
# which default to 991:991. So a container started as 0:0 still ends
# up running as 991, and 991 maps to a host sub-UID that does not own
# the bind mount: Synapse then dies reading its own chmod-600
# homeserver.yaml. Pinning them to the same identity as USER_TAG
# stops the entrypoint dropping to a user that cannot read anything.
- UID=MATRIX_RUN_UID_DATA #LIBREPORTAL|MATRIX_RUN_UID_TAG|MATRIX_RUN_UID_DATA
- GID=MATRIX_RUN_GID_DATA #LIBREPORTAL|MATRIX_RUN_GID_TAG|MATRIX_RUN_GID_DATA
depends_on:
- matrix-postgres
labels:

View File

@ -13,14 +13,15 @@
# ENABLE_REGISTRATION = if true, anyone who can reach the homeserver can create an account on it
# ADMIN_USERNAME = localpart of the first admin account created at install (the full ID becomes @<name>:<server_name>)
# ADMIN_PASSWORD = password for that first admin account
# SERVER_NAME = permanent identity of this homeserver — the half of a user ID after the colon
# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed)
#
CFG_MATRIX_APP_NAME=matrix
# A domain and Traefik are hard requirements, not conveniences. Synapse bakes
# server_name into every event and user ID it has ever signed, and it CANNOT be
# changed afterwards without throwing the database away — so the homeserver must
# know its real public name at install time, over real TLS.
CFG_MATRIX_REQUIRES="domain,traefik"
# No prerequisites. Synapse runs perfectly well on a plain HTTP listener reached
# by IP over the LAN or a WireGuard tunnel — that is the same setup as sitting
# behind a reverse proxy, minus the proxy. Only *federation* needs a real domain
# with public DNS and TLS, and federation is optional.
CFG_MATRIX_REQUIRES=""
CFG_MATRIX_BACKUP=true
CFG_MATRIX_BACKUP_STRATEGY=auto
CFG_MATRIX_UPDATE_TYPE=auto
@ -37,6 +38,21 @@ CFG_MATRIX_HEADSCALE=false
CFG_MATRIX_ENABLE_REGISTRATION=false
CFG_MATRIX_ADMIN_USERNAME=admin
CFG_MATRIX_ADMIN_PASSWORD_1=RANDOMIZEDPASSWORD1
# Set this if you ever intend to federate — it is the single most consequential
# value here and the ONLY one that cannot be changed later. It is signed into
# every event this server sends and forms the half of a user ID after the colon
# (@alice:example.com), so changing it orphans the database.
#
# Crucially it is independent of how clients reach the server: you can set it to
# a domain you own that has no DNS pointing anywhere yet, run today on
# http://<lan-ip>:<port>, and switch federation on later by adding DNS, Traefik
# and a certificate — with no rebuild and no lost history.
#
# Left empty it is derived: <subdomain>.<domain> when a domain is configured,
# otherwise this machine's LAN address. The LAN address works fine for local and
# WireGuard use but can never federate, and every user ID breaks if the IP
# changes — so if in doubt, put a domain here even if you do not use it yet.
CFG_MATRIX_SERVER_NAME=
CFG_MATRIX_MONITORING=false
# Postgres password for the `synapse` role, fed to the compose via
# #LIBREPORTAL|MATRIX_DB_PASSWORD_1_TAG| and written into homeserver.yaml by the
@ -62,7 +78,6 @@ CFG_MATRIX_DESCRIPTION="Federated Chat"
CFG_MATRIX_LONG_DESCRIPTION="The open federated chat protocol — rooms, end-to-end encryption and bridges to Discord and Slack. Installs Synapse plus the Element web client"
CFG_MATRIX_URL="https://github.com/element-hq/synapse"
CFG_MATRIX_ACTIONS="configure|install|restart|shutdown|uninstall"
CFG_MATRIX_REQUIRES_SERVICE=traefik
#
# =============================================================================
# NETWORK CONFIGURATION

View File

@ -1,8 +1,8 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://ELEMENT_HOMESERVER_PLACEHOLDER",
"server_name": "ELEMENT_HOMESERVER_PLACEHOLDER"
"base_url": "ELEMENT_BASEURL_PLACEHOLDER",
"server_name": "ELEMENT_SERVERNAME_PLACEHOLDER"
}
},
"brand": "Element",
@ -13,7 +13,7 @@
"default_country_code": "GB",
"show_labs_settings": true,
"room_directory": {
"servers": ["ELEMENT_HOMESERVER_PLACEHOLDER", "matrix.org"]
"servers": ["ELEMENT_SERVERNAME_PLACEHOLDER"]
},
"setting_defaults": {
"breadcrumbs": true

View File

@ -10,15 +10,20 @@
# sent and forms the second half of every user ID (@alice:<server_name>).
# Changing it later does not migrate anything — it orphans the whole database.
server_name: "SYNAPSE_SERVER_NAME_PLACEHOLDER"
public_baseurl: "https://SYNAPSE_SERVER_NAME_PLACEHOLDER/"
# Where clients actually reach this server. Independent of server_name: on a
# LAN or WireGuard-only install this is http://<ip>:<port> while server_name
# stays whatever permanent identity you chose. Adding TLS later means changing
# only this line.
public_baseurl: "SYNAPSE_PUBLIC_BASEURL_PLACEHOLDER"
pid_file: /data/homeserver.pid
# Serve /.well-known/matrix/server ourselves, advertising port 443. Federation
# otherwise defaults to port 8448 on server_name, which Traefik is not
# listening on. Because server_name is the same host Traefik already terminates
# TLS for, this is all the delegation that is needed — nothing has to be
# published at the apex domain.
serve_server_wellknown: true
# listening on. Only switched on for a real HTTPS deployment — advertising
# <server_name>:443 from an install that is only reachable at http://<ip>:<port>
# would just invite other servers to connect somewhere that cannot answer.
serve_server_wellknown: SYNAPSE_SERVE_WELLKNOWN_PLACEHOLDER
listeners:
# Port 8008 is the container-internal port and is deliberately hardcoded: it

View File

@ -0,0 +1,27 @@
#!/bin/bash
# App-specific compose tags for Matrix.
#
# The Synapse image drops privileges from root to its UID/GID environment
# variables (991:991 by default) before starting. That has to land on the same
# identity USER_TAG resolves to, or the process ends up as a host sub-UID that
# owns none of the bind-mounted data and cannot even read homeserver.yaml.
#
# The rule is deliberately the same one docker_config_setup_data.sh applies to
# USER_TAG: container-root under rootless (which IS the install user on the
# host), the real uid:gid under rooted. Kept here rather than parsing USER_DATA
# back out of the compose because the ordering between the two substitutions is
# not guaranteed.
appSetupComposeTags_matrix() {
local full_file_path="$1"
local run_uid=0 run_gid=0
if [[ "$CFG_DOCKER_INSTALL_TYPE" != "rootless" ]]; then
local container_user="${docker_install_user:-$sudo_user_name}"
run_uid=$(id -u "$container_user" 2>/dev/null) || run_uid=0
run_gid=$(id -g "$container_user" 2>/dev/null) || run_gid=0
fi
tagsManagerUpdateUniversalTag "$full_file_path" "MATRIX_RUN_UID_TAG" "$run_uid"
tagsManagerUpdateUniversalTag "$full_file_path" "MATRIX_RUN_GID_TAG" "$run_gid"
}

View File

@ -15,6 +15,14 @@ matrix_install_pre()
matrix=n
return 1
fi
# Not a prerequisite, just the one warning worth making loudly. Everything
# else about this install can be changed later; server_name cannot.
if [[ -z "$CFG_MATRIX_SERVER_NAME" && -z "$domain_full" ]]; then
isNotice "No domain configured — this homeserver will identify itself as '${public_ip_v4:-localhost}'."
isNotice "That works for LAN and WireGuard clients but can never federate, and every"
isNotice "user ID breaks if this machine's IP changes. To keep the door open, set"
isNotice "CFG_MATRIX_SERVER_NAME to a domain you own — it needs no DNS today."
fi
}
# The homeserver's public host — the value that becomes server_name.
@ -30,15 +38,57 @@ matrix_install_pre()
# here and the Traefik rule generated later cannot drift apart.
_matrixServerName()
{
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 "matrix.${domain_full}"
# An explicit choice always wins — this is the escape hatch that lets a
# LAN-only install still be given a federatable identity up front.
if [[ -n "$CFG_MATRIX_SERVER_NAME" ]]; then
echo "$CFG_MATRIX_SERVER_NAME"
return 0
fi
if [[ -n "$domain_full" ]]; then
local sub="${port_subdomains[0]}"
if [[ "$sub" == "@" || "$sub" == "root" ]]; then
echo "$domain_full"
elif [[ -n "$sub" ]]; then
echo "${sub}.${domain_full}"
else
echo "matrix.${domain_full}"
fi
return 0
fi
# LAN / WireGuard only. Usable, never federatable.
echo "${public_ip_v4:-localhost}"
}
# How clients reach the homeserver, as opposed to what it calls itself.
#
# Depends on the allocated external port, so it is only correct once ports have
# been assigned — i.e. from install_post_start onward, not install_post_compose.
_matrixPublicBaseUrl()
{
local app_name="$1"
local compose="$containers_dir$app_name/docker-compose.yml"
# Real HTTPS only when Traefik is actually installed AND a domain exists;
# otherwise the router in the compose is decorative.
if [[ -d "${containers_dir}traefik" && -n "$domain_full" ]]; then
local host
host=$(tagsManagerGetTagContent "$compose" "DOMAINSUBNAME_TAG_1")
if [[ -n "$host" && "$host" != DOMAINSUBNAME_DATA* ]]; then
echo "https://${host}/"
return 0
fi
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}/"
return 0
fi
# Ports not settled yet — the post-start pass will correct this.
echo "http://${public_ip_v4:-localhost}/"
}
matrix_install_post_compose()
@ -55,8 +105,8 @@ matrix_install_post_compose()
local server_name
server_name=$(_matrixServerName)
if [[ -z "$server_name" ]]; then
isError "No domain is configured — cannot determine the Matrix server_name."
isNotice "Set CFG_DOMAIN_${CFG_MATRIX_DOMAIN:-1} (General → Network), then reinstall."
isError "Could not determine a Matrix server_name and could not fall back to this host's address."
isNotice "Set CFG_MATRIX_SERVER_NAME in matrix.config, then reinstall."
return 1
fi
@ -75,6 +125,37 @@ matrix_install_post_compose()
result=$(createFolders "loud" "$docker_install_user" "$data_dir" "$app_dir/element")
checkSuccess "Creating $app_name data folders"
# Best guess now; the post-start pass recomputes it once the port is known
# and rewrites + restarts if it turns out different.
local base_url serve_wellknown="false"
base_url=$(_matrixPublicBaseUrl "$app_name")
[[ "$base_url" == https://* ]] && serve_wellknown="true"
# Element is a static bundle; config.json is the only thing that makes it
# point at this homeserver rather than matrix.org.
#
# Written before the signing key on purpose. config.json is bind-mounted as
# a FILE, and docker silently creates a directory at a missing mount source
# — so if an earlier step fails and returns, compose-up leaves a directory
# here that every later run then trips over. Doing it first means the file
# always exists; the rm handles a landmine left by an install that failed
# before this reordering.
[[ -d "$app_dir/element/config.json" ]] && runFileOp rm -rf "$app_dir/element/config.json"
result=$(copyResource "$app_name" "element-config.json" "element" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
checkSuccess "Copying Element configuration to $app_dir/element"
result=$(runFileOp mv "$app_dir/element/element-config.json" "$app_dir/element/config.json")
checkSuccess "Renaming Element configuration to config.json"
# base_url is where the browser sends requests; server_name is only the
# label. On a LAN install these genuinely differ — http://10.0.0.5:8008 vs
# whatever permanent name the server answers to.
runFileOp sed -i "s|ELEMENT_BASEURL_PLACEHOLDER|${base_url%/}|g" "$app_dir/element/config.json"
runFileOp sed -i "s|ELEMENT_SERVERNAME_PLACEHOLDER|$server_name|g" "$app_dir/element/config.json"
runFileOp chown -R "$docker_install_user":"$docker_install_user" "$app_dir/element"
checkSuccess "Pointing Element at ${base_url%/}"
# Synapse signs every federated event with this key, and a peer that has
# seen one key will reject events signed by a different one. So: generate it
# exactly once, and never regenerate it over an existing install.
@ -88,7 +169,20 @@ matrix_install_post_compose()
synapse_image=$(tagsManagerGetTagContent "$app_dir/docker-compose.yml" "MATRIX_VERSION_TAG")
synapse_image="matrixdotorg/synapse:${synapse_image:-latest}"
# UID/GID are the image's own knobs for who it writes /data as; left at
# their default of 991 the generated key lands owned by a host sub-UID
# that the install user does not own, and the mv/rm below fail with
# EACCES on files we just created. Pin them to the same identity the
# compose runs the container as (USER_TAG): container-root under
# rootless, the real uid:gid under rooted.
local gen_uid=0 gen_gid=0
if [[ "$CFG_DOCKER_INSTALL_TYPE" != "rootless" ]]; then
gen_uid=$(id -u "${docker_install_user:-$sudo_user_name}" 2>/dev/null || echo 0)
gen_gid=$(id -g "${docker_install_user:-$sudo_user_name}" 2>/dev/null || echo 0)
fi
result=$(runFileOp docker run --rm \
-e UID="$gen_uid" -e GID="$gen_gid" \
-e SYNAPSE_SERVER_NAME="$server_name" \
-e SYNAPSE_REPORT_STATS=no \
-v "$data_dir":/data \
@ -133,6 +227,8 @@ matrix_install_post_compose()
local enable_registration="false"
[[ "$CFG_MATRIX_ENABLE_REGISTRATION" == "true" ]] && enable_registration="true"
runFileOp sed -i "s|SYNAPSE_PUBLIC_BASEURL_PLACEHOLDER|$base_url|g" "$homeserver_file"
runFileOp sed -i "s|SYNAPSE_SERVE_WELLKNOWN_PLACEHOLDER|$serve_wellknown|g" "$homeserver_file"
runFileOp sed -i "s|SYNAPSE_SERVER_NAME_PLACEHOLDER|$server_name|g" "$homeserver_file"
runFileOp sed -i "s|SYNAPSE_DB_PASSWORD_PLACEHOLDER|$db_password|g" "$homeserver_file"
runFileOp sed -i "s|SYNAPSE_REGISTRATION_SECRET_PLACEHOLDER|$registration_secret|g" "$homeserver_file"
@ -145,28 +241,32 @@ matrix_install_post_compose()
runFileOp chown -R "$docker_install_user":"$docker_install_user" "$data_dir"
checkSuccess "Restricting permissions on the Synapse secrets"
# Element is a static bundle; config.json is the only thing that makes it
# point at this homeserver rather than matrix.org.
result=$(copyResource "$app_name" "element-config.json" "element" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
checkSuccess "Copying Element configuration to $app_dir/element"
result=$(runFileOp mv "$app_dir/element/element-config.json" "$app_dir/element/config.json")
checkSuccess "Renaming Element configuration to config.json"
runFileOp sed -i "s|ELEMENT_HOMESERVER_PLACEHOLDER|$server_name|g" "$app_dir/element/config.json"
runFileOp chown -R "$docker_install_user":"$docker_install_user" "$app_dir/element"
checkSuccess "Pointing Element at https://$server_name"
}
matrix_install_post_start()
{
local app_name="$1"
local app_dir="$containers_dir$app_name"
((menu_number++))
echo ""
echo "---- $menu_number. Creating the first Matrix admin account"
echo ""
# Ports are only allocated during compose-up, so the base URL written by
# install_post_compose was a guess whenever there is no domain. Now that the
# real external port is known, correct it — and only restart if it actually
# changed, so a domain-backed install pays nothing for this.
local base_url current_base
base_url=$(_matrixPublicBaseUrl "$app_name")
current_base=$(runFileOp grep -oP '^public_baseurl:\s*"\K[^"]*' "$app_dir/data/homeserver.yaml" 2>/dev/null)
if [[ -n "$base_url" && "$base_url" != "$current_base" ]]; then
runFileOp sed -i "s|^public_baseurl: .*|public_baseurl: \"$base_url\"|" "$app_dir/data/homeserver.yaml"
runFileOp sed -i "s|\"base_url\": \".*\"|\"base_url\": \"${base_url%/}\"|" "$app_dir/element/config.json"
isSuccessful "Homeserver URL settled as ${base_url%/} (was ${current_base:-unset})"
dockerComposeRestart "$app_name"
fi
# Synapse runs its database migrations on first boot, which on an empty
# Postgres takes appreciably longer than the container takes to start.
# /health answers only once it is actually serving.
@ -201,14 +301,20 @@ matrix_install_post_start()
# Idempotent in practice: on a reinstall over existing data the account
# already exists and register_new_matrix_user fails with "User ID already
# taken", which is not worth failing the install over.
local result
# rc is captured on its own line because the [[ ]] test below overwrites $?
# — checkSuccess would then report the *test's* result, which is how a
# perfectly successful registration got logged as an error.
local result rc
result=$(runFileOp docker exec matrix-synapse register_new_matrix_user \
-u "$admin_user" -p "$admin_pass" -a \
-c /data/homeserver.yaml http://localhost:8008 2>&1)
rc=$?
if [[ "$result" == *"already taken"* ]]; then
isNotice "Matrix admin '$admin_user' already exists — leaving the existing account alone."
elif (( rc == 0 )); then
isSuccessful "Created Matrix admin account '$admin_user'"
else
checkSuccess "Creating Matrix admin account '$admin_user'"
isError "Creating Matrix admin account '$admin_user' failed: $result"
fi
}
@ -226,10 +332,18 @@ matrix_install_post()
echo " Your user ID : @${admin_user}:${server_name}"
echo " Password : ${CFG_MATRIX_ADMIN_PASSWORD_1}"
echo ""
echo " Sign in through the Element web interface, or any Matrix client"
echo " (Element mobile/desktop, FluffyChat, Nheko) using the server name"
echo " above."
echo " Sign in through the Element web interface, or point any Matrix"
echo " client (FluffyChat, Nheko, Element desktop) at the homeserver URL"
echo " shown above."
echo ""
if [[ "$(_matrixPublicBaseUrl "$app_name")" != https://* ]]; then
echo " This install serves plain HTTP, which is fine over the LAN or a"
echo " WireGuard tunnel. Two consequences worth knowing:"
echo " • It cannot federate — other Matrix servers need public DNS + TLS."
echo " • Element's mobile apps expect an HTTPS homeserver; desktop and"
echo " web clients are unaffected."
echo ""
fi
echo " Registration is ${CFG_MATRIX_ENABLE_REGISTRATION:-false}. To invite"
echo " others while it stays closed, create their accounts with:"
echo " docker exec -it matrix-synapse register_new_matrix_user \\"

View File

@ -43,6 +43,97 @@ _stoatDomain()
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://${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"
# 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
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()
@ -95,14 +186,6 @@ stoat_install_post_compose()
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" \
@ -122,62 +205,17 @@ stoat_install_post_compose()
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})"
# The port is not allocated yet, so this is a guess whenever there is no
# domain. The files still have to exist now: they are bind-mounted, and
# docker would silently create directories in their place otherwise.
# stoat_install_post_start rewrites them once the real port is known.
local base
base=$(_stoatBaseUrl "$app_name")
_stoatWriteUrlFiles "$app_dir" "$base" "$video_enabled"
checkSuccess "Writing .env.web, stoat.json and Revolt.toml for $base"
# 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
@ -212,18 +250,57 @@ EOF
checkSuccess "Setting ownership on the $app_name install directory"
}
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}"
[[ "$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"
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 domain
domain=$(_stoatDomain)
local base
base=$(_stoatBaseUrl "$app_name")
echo ""
isNotice "Stoat first run:"
echo ""
echo " Open https://${domain} and create an account — the first account"
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"

View File

@ -10,14 +10,17 @@
# HEALTHCHECK = if true, default docker health checks for that container will be enabled
# AUTHELIA = if true, use Authelia authentication, if false turned off.
# HEADSCALE = options : false, local, remote (see general config). e.g false or local,remote
# ENABLE_VIDEO = if true, allow camera and screen sharing (voice always works)
# ENABLE_VIDEO = if true, allow camera and screen sharing (needs an HTTPS origin to work in a browser)
# MONITORING = if true, export this app's metrics to Prometheus + Grafana (needs both apps installed)
#
CFG_STOAT_APP_NAME=stoat
# Stoat bakes its public URL into the client bundle and into Revolt.toml at
# install time, and voice needs real TLS, so a domain behind Traefik is a
# prerequisite rather than a nicety.
CFG_STOAT_REQUIRES="domain,traefik"
# No prerequisites. Stoat bakes its public URL into the client bundle, but that
# URL can just as well be http://<lan-ip>:<port> — text chat, channels, roles
# and uploads all work that way over a LAN or WireGuard tunnel. Only camera and
# microphone need an HTTPS origin, because browsers refuse getUserMedia outside
# a secure context; a VPN does not change that, since the check is on the URL
# scheme. Any HTTPS satisfies it, including a self-signed Traefik certificate.
CFG_STOAT_REQUIRES=""
CFG_STOAT_BACKUP=true
CFG_STOAT_BACKUP_STRATEGY=auto
# Manual, deliberately. This is a sixteen-service stack whose components are
@ -58,7 +61,6 @@ CFG_STOAT_DESCRIPTION="Discord-style Chat"
CFG_STOAT_LONG_DESCRIPTION="The closest open-source match to Discord — servers, channels, roles, and LiveKit voice and video. Heavy (16 containers) and does not federate"
CFG_STOAT_URL="https://github.com/stoatchat/self-hosted"
CFG_STOAT_ACTIONS="configure|install|restart|shutdown|uninstall"
CFG_STOAT_REQUIRES_SERVICE=traefik
#
# =============================================================================
# NETWORK CONFIGURATION

View File

@ -77,6 +77,7 @@ declare -gA LP_FN_MAP=(
[_appReqServiceMsg]="checks/requirements/check_app_install.sh"
[appScanAvailable]="app/app_scan_available.sh"
[appSetupComposeTags_gluetun]="gluetun/scripts/gluetun_compose_tags.sh"
[appSetupComposeTags_matrix]="matrix/scripts/matrix_compose_tags.sh"
[appSetupComposeTags_nextcloud]="nextcloud/scripts/nextcloud_compose_tags.sh"
[appSetupComposeTags_pihole]="pihole/scripts/pihole_compose_tags.sh"
[appSetupComposeTags_searxng]="searxng/scripts/searxng_compose_tags.sh"
@ -651,6 +652,7 @@ declare -gA LP_FN_MAP=(
[matrix_install_post_compose]="matrix/scripts/matrix_install_hooks.sh"
[matrix_install_post_start]="matrix/scripts/matrix_install_hooks.sh"
[matrix_install_pre]="matrix/scripts/matrix_install_hooks.sh"
[_matrixPublicBaseUrl]="matrix/scripts/matrix_install_hooks.sh"
[_matrixServerName]="matrix/scripts/matrix_install_hooks.sh"
[mattermostToolsMenu]="menu/tools/manage_mattermost.sh"
[maybeRegenPoll]="task/crontab_task_processor.sh"
@ -885,11 +887,14 @@ declare -gA LP_FN_MAP=(
[startOther]="start/start_other.sh"
[startPreInstall]="start/start_preinstall.sh"
[startScan]="start/start_scan.sh"
[_stoatBaseUrl]="stoat/scripts/stoat_install_hooks.sh"
[_stoatDomain]="stoat/scripts/stoat_install_hooks.sh"
[stoat_install_post]="stoat/scripts/stoat_install_hooks.sh"
[stoat_install_post_compose]="stoat/scripts/stoat_install_hooks.sh"
[stoat_install_post_start]="stoat/scripts/stoat_install_hooks.sh"
[stoat_install_pre]="stoat/scripts/stoat_install_hooks.sh"
[_stoatWriteSecrets]="stoat/scripts/stoat_install_hooks.sh"
[_stoatWriteUrlFiles]="stoat/scripts/stoat_install_hooks.sh"
[stopCrowdsec]="crowdsec/crowdsec.sh"
[switchMigrateBackupApps]="docker/type_switcher/swap_docker_type.sh"
[switchMigrateRestoreApps]="docker/type_switcher/swap_docker_type.sh"
@ -1133,6 +1138,7 @@ declare -gA LP_FN_ROOT=(
[_appReqServiceMsg]="scripts"
[appScanAvailable]="scripts"
[appSetupComposeTags_gluetun]="containers"
[appSetupComposeTags_matrix]="containers"
[appSetupComposeTags_nextcloud]="containers"
[appSetupComposeTags_pihole]="containers"
[appSetupComposeTags_searxng]="containers"
@ -1707,6 +1713,7 @@ declare -gA LP_FN_ROOT=(
[matrix_install_post_compose]="containers"
[matrix_install_post_start]="containers"
[matrix_install_pre]="containers"
[_matrixPublicBaseUrl]="containers"
[_matrixServerName]="containers"
[mattermostToolsMenu]="scripts"
[maybeRegenPoll]="scripts"
@ -1941,11 +1948,14 @@ declare -gA LP_FN_ROOT=(
[startOther]="scripts"
[startPreInstall]="scripts"
[startScan]="scripts"
[_stoatBaseUrl]="containers"
[_stoatDomain]="containers"
[stoat_install_post]="containers"
[stoat_install_post_compose]="containers"
[stoat_install_post_start]="containers"
[stoat_install_pre]="containers"
[_stoatWriteSecrets]="containers"
[_stoatWriteUrlFiles]="containers"
[stopCrowdsec]="containers"
[switchMigrateBackupApps]="scripts"
[switchMigrateRestoreApps]="scripts"
@ -2223,6 +2233,7 @@ _appReqServiceInstalled() { unset -f _appReqServiceInstalled; __lpAutoload "${in
_appReqServiceMsg() { unset -f _appReqServiceMsg; __lpAutoload "${install_scripts_dir}checks/requirements/check_app_install.sh"; _appReqServiceMsg "$@"; }
appScanAvailable() { unset -f appScanAvailable; __lpAutoload "${install_scripts_dir}app/app_scan_available.sh"; appScanAvailable "$@"; }
appSetupComposeTags_gluetun() { unset -f appSetupComposeTags_gluetun; __lpAutoload "${install_containers_dir}gluetun/scripts/gluetun_compose_tags.sh"; appSetupComposeTags_gluetun "$@"; }
appSetupComposeTags_matrix() { unset -f appSetupComposeTags_matrix; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_compose_tags.sh"; appSetupComposeTags_matrix "$@"; }
appSetupComposeTags_nextcloud() { unset -f appSetupComposeTags_nextcloud; __lpAutoload "${install_containers_dir}nextcloud/scripts/nextcloud_compose_tags.sh"; appSetupComposeTags_nextcloud "$@"; }
appSetupComposeTags_pihole() { unset -f appSetupComposeTags_pihole; __lpAutoload "${install_containers_dir}pihole/scripts/pihole_compose_tags.sh"; appSetupComposeTags_pihole "$@"; }
appSetupComposeTags_searxng() { unset -f appSetupComposeTags_searxng; __lpAutoload "${install_containers_dir}searxng/scripts/searxng_compose_tags.sh"; appSetupComposeTags_searxng "$@"; }
@ -2797,6 +2808,7 @@ matrix_install_post() { unset -f matrix_install_post; __lpAutoload "${install_co
matrix_install_post_compose() { unset -f matrix_install_post_compose; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_install_hooks.sh"; matrix_install_post_compose "$@"; }
matrix_install_post_start() { unset -f matrix_install_post_start; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_install_hooks.sh"; matrix_install_post_start "$@"; }
matrix_install_pre() { unset -f matrix_install_pre; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_install_hooks.sh"; matrix_install_pre "$@"; }
_matrixPublicBaseUrl() { unset -f _matrixPublicBaseUrl; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_install_hooks.sh"; _matrixPublicBaseUrl "$@"; }
_matrixServerName() { unset -f _matrixServerName; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_install_hooks.sh"; _matrixServerName "$@"; }
mattermostToolsMenu() { unset -f mattermostToolsMenu; __lpAutoload "${install_scripts_dir}menu/tools/manage_mattermost.sh"; mattermostToolsMenu "$@"; }
maybeRegenPoll() { unset -f maybeRegenPoll; __lpAutoload "${install_scripts_dir}task/crontab_task_processor.sh"; maybeRegenPoll "$@"; }
@ -3031,11 +3043,14 @@ startLoad() { unset -f startLoad; __lpAutoload "${install_scripts_dir}start/star
startOther() { unset -f startOther; __lpAutoload "${install_scripts_dir}start/start_other.sh"; startOther "$@"; }
startPreInstall() { unset -f startPreInstall; __lpAutoload "${install_scripts_dir}start/start_preinstall.sh"; startPreInstall "$@"; }
startScan() { unset -f startScan; __lpAutoload "${install_scripts_dir}start/start_scan.sh"; startScan "$@"; }
_stoatBaseUrl() { unset -f _stoatBaseUrl; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatBaseUrl "$@"; }
_stoatDomain() { unset -f _stoatDomain; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatDomain "$@"; }
stoat_install_post() { unset -f stoat_install_post; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_post "$@"; }
stoat_install_post_compose() { unset -f stoat_install_post_compose; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_post_compose "$@"; }
stoat_install_post_start() { unset -f stoat_install_post_start; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_post_start "$@"; }
stoat_install_pre() { unset -f stoat_install_pre; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; stoat_install_pre "$@"; }
_stoatWriteSecrets() { unset -f _stoatWriteSecrets; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatWriteSecrets "$@"; }
_stoatWriteUrlFiles() { unset -f _stoatWriteUrlFiles; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatWriteUrlFiles "$@"; }
stopCrowdsec() { unset -f stopCrowdsec; __lpAutoload "${install_containers_dir}crowdsec/crowdsec.sh"; stopCrowdsec "$@"; }
switchMigrateBackupApps() { unset -f switchMigrateBackupApps; __lpAutoload "${install_scripts_dir}docker/type_switcher/swap_docker_type.sh"; switchMigrateBackupApps "$@"; }
switchMigrateRestoreApps() { unset -f switchMigrateRestoreApps; __lpAutoload "${install_scripts_dir}docker/type_switcher/swap_docker_type.sh"; switchMigrateRestoreApps "$@"; }