linkding_auth.sh persists ADMIN_USER and ADMIN_PASSWORD when the first admin is created, and keeps the password in step on later resets of that account, but linkding.config declared neither — so both writes were no-ops and the WebUI credentials card never had anything to show. Predates the slot work; it only became visible once authPersistCfg started warning instead of failing silently. Added empty rather than RANDOMIZED*, because unlike bookstack or nextcloud nothing seeds a linkding account at install — the first user is created from the WebUI. A generated password would name an account that does not exist, and the card would display a password that cannot log in. Unslotted for the same reason: the slot number marks a value the installer generates, and this one is written at runtime by the tool. No AUTH_PROFILE key: nothing reads it (it exists only in a comment in auth_adapter.sh), and adding an unread key is what was just cleaned up elsewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
353 lines
16 KiB
Bash
353 lines
16 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"
|
|
runFileOp chown -R "$docker_install_user":"$docker_install_user" "$data_dir"
|
|
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 ""
|
|
}
|