The "Restricting permissions on the Synapse secrets" step chowned $app_dir/data recursively, which also walks data/media_store — files written by Synapse itself. Under rootless that is invisible (container root maps to the docker install user, so everything is chownable), but in rooted mode container root IS host root: the chown runs as the manager and would fail per file, then fail the step, over files that must keep their own ownership anyway. Same shape as the stoat fix, caught before it bit. Scoped to the top-level files the hook actually writes — homeserver.yaml, log.yaml, signing.key, .lp-admin-token — which is what the step name means. Note this app was NOT producing the stoat symptom today: matrix's postgres data lives at $app_dir/postgres, outside the directory being walked. element/ keeps its recursive chown: one LibrePortal-written config.json the container never writes. Verified: reinstall clean, exit 0, homeserver.yaml + signing.key still 0600, media_store untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
361 lines
17 KiB
Bash
361 lines
17 KiB
Bash
#!/bin/bash
|
|
|
|
# Matrix (Synapse + Element) install hooks.
|
|
#
|
|
# Synapse takes essentially no configuration from the environment — it reads
|
|
# homeserver.yaml and nothing else — so the real install work is done here:
|
|
# generate the signing key, write homeserver.yaml from the template with the
|
|
# server name and secrets filled in, write Element's config.json, and create the
|
|
# first admin account once the homeserver is answering.
|
|
|
|
matrix_install_pre()
|
|
{
|
|
local app_name="$1"
|
|
if ! appInstallCheckRequirements "$app_name" "$CFG_MATRIX_REQUIRES"; then
|
|
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.
|
|
#
|
|
# Built from the port arrays and $domain_full that variables_init_app has
|
|
# already put in scope, NOT by reading DOMAINSUBNAME_TAG_1 out of the deployed
|
|
# compose: install_post_compose runs before dockerConfigSetupFileWithData, so at
|
|
# that point the compose still holds raw placeholders.
|
|
#
|
|
# port_subdomains[0] is CFG_MATRIX_PORT_1, the Synapse router — deliberately not
|
|
# $host_setup, which for this two-host app resolves to Element instead. The
|
|
# empty/@/root cases mirror tagsProcessorPortSubdomains so the name computed
|
|
# here and the Traefik rule generated later cannot drift apart.
|
|
_matrixServerName()
|
|
{
|
|
# 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()
|
|
{
|
|
local app_name="$1"
|
|
local app_dir="$containers_dir$app_name"
|
|
local data_dir="$app_dir/data"
|
|
|
|
((menu_number++))
|
|
echo ""
|
|
echo "---- $menu_number. Generating the Synapse homeserver configuration"
|
|
echo ""
|
|
|
|
local server_name
|
|
server_name=$(_matrixServerName)
|
|
if [[ -z "$server_name" ]]; then
|
|
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
|
|
|
|
# Same value the compose will hand to Postgres. Taken from the config rather
|
|
# than the compose because the compose is not substituted yet at this point;
|
|
# the config is where the secret is generated and remembered, and the
|
|
# #LIBREPORTAL|MATRIX_DB_PASSWORD_1_TAG| line is filled from this very
|
|
# variable a step later, so the two cannot disagree.
|
|
local db_password="$CFG_MATRIX_DB_PASSWORD_1"
|
|
if [[ -z "$db_password" || "$db_password" == RANDOMIZEDPASSWORD* ]]; then
|
|
isError "CFG_MATRIX_DB_PASSWORD_1 was not generated — aborting Synapse configuration."
|
|
return 1
|
|
fi
|
|
|
|
local result
|
|
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.
|
|
#
|
|
# Upstream's `generate` command is used rather than hand-rolling the key
|
|
# file, because the format encodes a key ID that other servers cache. It
|
|
# also emits a homeserver.yaml and a log config, which we throw away in
|
|
# favour of the templates below.
|
|
if [[ ! -s "$data_dir/signing.key" ]]; then
|
|
local synapse_image
|
|
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 \
|
|
"$synapse_image" generate 2>&1)
|
|
checkSuccess "Generating the Synapse signing key with $synapse_image"
|
|
|
|
# `generate` names the key after the server; homeserver.yaml expects it
|
|
# at a fixed path so the file does not have to be renamed if the app is
|
|
# ever restored under a different name.
|
|
if [[ -f "$data_dir/$server_name.signing.key" ]]; then
|
|
result=$(runFileOp mv "$data_dir/$server_name.signing.key" "$data_dir/signing.key")
|
|
checkSuccess "Storing the signing key at data/signing.key"
|
|
fi
|
|
# Ours replace both of these.
|
|
result=$(runFileOp rm -f "$data_dir/homeserver.yaml" "$data_dir/$server_name.log.config")
|
|
checkSuccess "Discarding the generated config in favour of the LibrePortal template"
|
|
else
|
|
isNotice "An existing signing key was found — keeping it (regenerating would break federation)."
|
|
fi
|
|
|
|
if [[ ! -s "$data_dir/signing.key" ]]; then
|
|
isError "No signing key was produced — Synapse will not start. Check that the image could be pulled."
|
|
return 1
|
|
fi
|
|
|
|
result=$(copyResource "$app_name" "homeserver.yaml" "data" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
|
|
checkSuccess "Copying homeserver.yaml to $data_dir"
|
|
|
|
result=$(copyResource "$app_name" "log.yaml" "data" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
|
|
checkSuccess "Copying log.yaml to $data_dir"
|
|
|
|
local homeserver_file="$data_dir/homeserver.yaml"
|
|
|
|
# Three independent secrets, each generated fresh. registration_shared_secret
|
|
# can mint an account on this server, so it is as sensitive as an admin
|
|
# password — it is why homeserver.yaml is chmod 600 below.
|
|
local registration_secret macaroon_secret form_secret
|
|
registration_secret=$(openssl rand -hex 32)
|
|
macaroon_secret=$(openssl rand -hex 32)
|
|
form_secret=$(openssl rand -hex 32)
|
|
|
|
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"
|
|
runFileOp sed -i "s|SYNAPSE_MACAROON_SECRET_PLACEHOLDER|$macaroon_secret|g" "$homeserver_file"
|
|
runFileOp sed -i "s|SYNAPSE_FORM_SECRET_PLACEHOLDER|$form_secret|g" "$homeserver_file"
|
|
runFileOp sed -i "s|SYNAPSE_ENABLE_REGISTRATION_PLACEHOLDER|$enable_registration|g" "$homeserver_file"
|
|
checkSuccess "Writing homeserver.yaml (server_name=$server_name registration=$enable_registration)"
|
|
|
|
runFileOp chmod 600 "$homeserver_file" "$data_dir/signing.key"
|
|
# Top-level files only (homeserver.yaml, log.yaml, signing.key, the admin
|
|
# token) — the ones this hook writes and the step name refers to. The
|
|
# recursion this replaces also walked data/media_store, whose contents belong
|
|
# to Synapse. That is harmless under rootless, where container-root maps to
|
|
# the docker install user, but in ROOTED mode those files are host-root and
|
|
# the chown runs as the manager: "Operation not permitted" per file plus a
|
|
# failed step, for files that must keep their own ownership regardless.
|
|
runFileOp find "$data_dir" -maxdepth 1 -type f \
|
|
-exec chown "$docker_install_user":"$docker_install_user" {} +
|
|
checkSuccess "Restricting permissions on the Synapse secrets"
|
|
|
|
}
|
|
|
|
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.
|
|
#
|
|
# Probed with python rather than curl: the Synapse image is debian-slim with
|
|
# no curl or wget in it, but python is what Synapse itself runs on, so it is
|
|
# always there.
|
|
local attempts=0
|
|
while ((attempts < 60)); do
|
|
if runFileOp docker exec matrix-synapse python -c \
|
|
"import urllib.request; urllib.request.urlopen('http://localhost:8008/health', timeout=5)" >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
sleep 2
|
|
((attempts++))
|
|
done
|
|
|
|
if ((attempts >= 60)); then
|
|
isError "Synapse did not become ready in time — no admin account was created."
|
|
isNotice "Check 'docker logs matrix-synapse'. Once it is up, create the account with:"
|
|
isNotice " docker exec -it matrix-synapse register_new_matrix_user -c /data/homeserver.yaml http://localhost:8008"
|
|
return 0
|
|
fi
|
|
|
|
local admin_user="${CFG_MATRIX_ADMIN_USERNAME:-admin}"
|
|
local admin_pass="${CFG_MATRIX_ADMIN_PASSWORD_1}"
|
|
if [[ -z "$admin_pass" || "$admin_pass" == RANDOMIZEDPASSWORD* ]]; then
|
|
isNotice "No admin password is set in matrix.config — skipping admin account creation."
|
|
return 0
|
|
fi
|
|
|
|
# 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.
|
|
# 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
|
|
isError "Creating Matrix admin account '$admin_user' failed: $result"
|
|
fi
|
|
}
|
|
|
|
matrix_install_post()
|
|
{
|
|
local app_name="$1"
|
|
local server_name
|
|
server_name=$(_matrixServerName)
|
|
local admin_user="${CFG_MATRIX_ADMIN_USERNAME:-admin}"
|
|
|
|
echo ""
|
|
isNotice "Matrix homeserver:"
|
|
echo ""
|
|
echo " Server name : ${server_name}"
|
|
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 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 \\"
|
|
echo " -c /data/homeserver.yaml http://localhost:8008"
|
|
echo ""
|
|
}
|