matrix: add Synapse + Element as a federated chat app

Synapse on Postgres plus the Element web client, on two subdomains: the
homeserver on matrix.<domain> (which becomes server_name, so IDs read
@alice:matrix.<domain>) and Element on element.<domain>.

Two hosts rather than one because server_name then matches the host Traefik
already terminates TLS for, so 'serve_server_wellknown: true' is all the
federation delegation needed and nothing has to be published at the apex
domain — which this app has no way to configure.

CFG_MATRIX_AUTHELIA is pinned false and documented: forward-auth in front of
/_matrix locks out every client and every federating peer, since they carry
Matrix access tokens and cannot follow a redirect. Real SSO goes through the
OIDC block in resources/homeserver.yaml instead.

The install hook generates the signing key once via upstream's own 'generate'
command and refuses to regenerate it over an existing install — a new key would
be rejected by every server that had cached the old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 05:28:31 +01:00
parent 9084280ea8
commit b9e334dc49
7 changed files with 594 additions and 0 deletions

View File

@ -0,0 +1,116 @@
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
external: true
services:
# Synapse — the homeserver. Everything that matters lives in
# ./data/homeserver.yaml, written by matrix_install_post_compose from
# resources/homeserver.yaml; Synapse takes no meaningful configuration from
# the environment, so there is little to see here.
matrix-synapse: #LIBREPORTAL|SERVICE_TAG_1|matrix-synapse
container_name: matrix-synapse
image: matrixdotorg/synapse:v1.158.0 #LIBREPORTAL|MATRIX_VERSION_TAG|v1.158.0
# Synapse writes the media store, and under rootless Docker the image's
# own uid maps to a host sub-UID that owns nothing. Same fix as the
# other apps: run as whoever owns the bind mounts.
user: "USER_DATA" #LIBREPORTAL|USER_TAG|USER_DATA
restart: unless-stopped
# GLUETUN_OFF_BEGIN
ports:
- "PORTS_DATA_1" #LIBREPORTAL|PORTS_TAG_1|PORTS_DATA_1
# GLUETUN_OFF_END
volumes:
- ./data:/data
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
- SYNAPSE_CONFIG_PATH=/data/homeserver.yaml
depends_on:
- matrix-postgres
labels:
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
libreportal.backup.db: "postgres:matrix-postgres:postgres:"
# The media store holds every uploaded file and avatar. It is not in
# the database, so without this it would not come back on restore.
libreportal.backup.files: "matrix-synapse:/data/media_store:data/media_store"
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
# TRAEFIK_PORT_1_BEGIN
traefik.http.routers.matrix-synapse.entrypoints: web,websecure
traefik.http.routers.matrix-synapse.rule: Host(`DOMAINSUBNAME_DATA_1`) #LIBREPORTAL|DOMAINSUBNAME_TAG_1|DOMAINSUBNAME_DATA_1
traefik.http.routers.matrix-synapse.tls: true
traefik.http.routers.matrix-synapse.tls.certresolver: production
traefik.http.services.matrix-synapse.loadbalancer.server.port: PORT_INTERNAL_DATA_1 #LIBREPORTAL|PORT_INTERNAL_TAG_1|PORT_INTERNAL_DATA_1
traefik.http.routers.matrix-synapse.middlewares: MIDDLEWARE_DATA_1 #LIBREPORTAL|MIDDLEWARE_TAG_1|MIDDLEWARE_DATA_1
# TRAEFIK_PORT_1_END
traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
healthcheck:
disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA
# GLUETUN_OFF_BEGIN
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_1 #LIBREPORTAL|IP_TAG_1|IP_DATA_1
# GLUETUN_OFF_END
# GLUETUN_ON_BEGIN
# network_mode: "container:gluetun-service"
# GLUETUN_ON_END
# Element web — a static single-page app served by nginx. It talks to
# Synapse from the user's browser, not server-side, so it needs no link to
# the homeserver container beyond the base_url baked into config.json.
matrix-element: #LIBREPORTAL|SERVICE_TAG_2|matrix-element
container_name: matrix-element
image: vectorim/element-web:v1.12.25 #LIBREPORTAL|MATRIX_ELEMENT_VERSION_TAG|v1.12.25
restart: unless-stopped
# GLUETUN_OFF_BEGIN
ports:
- "PORTS_DATA_2" #LIBREPORTAL|PORTS_TAG_2|PORTS_DATA_2
# GLUETUN_OFF_END
volumes:
- ./element/config.json:/app/config.json:ro
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
labels:
libreportal.category: "CATEGORY_DATA" #LIBREPORTAL|CATEGORY_TAG|CATEGORY_DATA
libreportal.title: "TITLE_DATA" #LIBREPORTAL|TITLE_TAG|TITLE_DATA
traefik.enable: TRAEFIK_ENABLE_DATA #LIBREPORTAL|TRAEFIK_ENABLE_TAG|TRAEFIK_ENABLE_DATA
# TRAEFIK_PORT_2_BEGIN
traefik.http.routers.matrix-element.entrypoints: web,websecure
traefik.http.routers.matrix-element.rule: Host(`DOMAINSUBNAME_DATA_2`) #LIBREPORTAL|DOMAINSUBNAME_TAG_2|DOMAINSUBNAME_DATA_2
traefik.http.routers.matrix-element.tls: true
traefik.http.routers.matrix-element.tls.certresolver: production
traefik.http.services.matrix-element.loadbalancer.server.port: PORT_INTERNAL_DATA_2 #LIBREPORTAL|PORT_INTERNAL_TAG_2|PORT_INTERNAL_DATA_2
traefik.http.routers.matrix-element.middlewares: MIDDLEWARE_DATA_2 #LIBREPORTAL|MIDDLEWARE_TAG_2|MIDDLEWARE_DATA_2
# TRAEFIK_PORT_2_END
traefik.docker.network: DOCKER_NETWORK_DATA #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
healthcheck:
disable: HEALTHCHECK_DATA #LIBREPORTAL|HEALTHCHECK_TAG|HEALTHCHECK_DATA
# GLUETUN_OFF_BEGIN
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_2 #LIBREPORTAL|IP_TAG_2|IP_DATA_2
# GLUETUN_OFF_END
# GLUETUN_ON_BEGIN
# network_mode: "container:gluetun-service"
# GLUETUN_ON_END
# No `user:` override — the postgres entrypoint starts as root, chowns
# PGDATA and drops privileges, which works under rootless because
# container-root is the install user that owns the mount.
matrix-postgres:
image: postgres:15-alpine
container_name: matrix-postgres
restart: unless-stopped
environment:
- TZ=TIMEZONE_DATA #LIBREPORTAL|TIMEZONE_TAG|TIMEZONE_DATA
- POSTGRES_USER=synapse
- POSTGRES_PASSWORD=PASSWORD_DATA_1 #LIBREPORTAL|PASSWORD_TAG_1|PASSWORD_DATA_1
- POSTGRES_DB=synapse
# Not optional. Synapse refuses to start against a database with any
# other collation or ctype — it needs deterministic byte ordering for
# its indexes, and a C.UTF-8 locale is the only thing that gives it.
- POSTGRES_INITDB_ARGS=--encoding=UTF8 --locale=C
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
DOCKER_NETWORK_DATA: #LIBREPORTAL|DOCKER_NETWORK_TAG|DOCKER_NETWORK_DATA
ipv4_address: IP_DATA_3 #LIBREPORTAL|IP_TAG_3|IP_DATA_3

View File

@ -0,0 +1,95 @@
#
# =============================================================================
# GENERAL CONFIGURATION
# =============================================================================
# APP_NAME = name of application for use in scripts
# REQUIRES = comma-separated install prerequisites (see scripts/checks/requirements/check_app_install.sh)
# COMPOSE_FILE = default for no app_name in docker-compose file name, app if there is
# BACKUP = if true, include this application in backup operations
# UPDATE_TYPE = auto: new image builds are applied automatically (a recovery snapshot is taken first), manual: only when you press Update
# 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_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
# 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"
CFG_MATRIX_BACKUP=true
CFG_MATRIX_BACKUP_STRATEGY=auto
CFG_MATRIX_UPDATE_TYPE=auto
CFG_MATRIX_COMPOSE_FILE=default
CFG_MATRIX_HEALTHCHECK=true
# Must stay false. Authelia's forward-auth would sit in front of /_matrix, which
# is the API every Matrix client and every federating server speaks — they
# authenticate with Matrix access tokens and cannot follow an Authelia redirect,
# so turning this on breaks all clients and federation at once. Synapse can do
# real SSO against Authelia instead, via the OIDC block in
# resources/homeserver.yaml.
CFG_MATRIX_AUTHELIA=false
CFG_MATRIX_HEADSCALE=false
CFG_MATRIX_ENABLE_REGISTRATION=false
CFG_MATRIX_ADMIN_USERNAME=admin
CFG_MATRIX_ADMIN_PASSWORD=RANDOMIZEDPASSWORD1
CFG_MATRIX_MONITORING=false
#
# =============================================================================
# METADATA
# =============================================================================
# CATEGORY = application category for grouping
# TITLE = display name for the application
# DESCRIPTION = short description of the application
# LONG_DESCRIPTION = detailed description of the application
# URL = source repository or documentation URL
# ACTIONS = available actions for this application
# REQUIRES_SERVICE = name of another LibrePortal app that must be installed before this one can be configured
#
CFG_MATRIX_CATEGORY="communication"
CFG_MATRIX_TITLE="Matrix"
CFG_MATRIX_DESCRIPTION="Federated Chat"
CFG_MATRIX_LONG_DESCRIPTION="Matrix is the open federated chat protocol — Spaces and rooms cover what Discord servers and channels do, with end-to-end encryption and bridges to Discord, IRC and Slack. This installs the Synapse homeserver on Postgres plus the Element web client, on their own subdomains. Because it federates, accounts on this server can talk to every other Matrix server without either side giving up control"
CFG_MATRIX_URL="https://github.com/element-hq/synapse"
CFG_MATRIX_ACTIONS="configure|install|restart|shutdown|uninstall"
CFG_MATRIX_REQUIRES_SERVICE=traefik
#
# =============================================================================
# NETWORK CONFIGURATION
# =============================================================================
# DOMAIN = number of domain from the general config, useful when using multiple domains
# WHITELIST = if true only allow whitelisted ips (see general config), if false allow all
#
CFG_MATRIX_DOMAIN=1
CFG_MATRIX_WHITELIST=false
CFG_MATRIX_NETWORK=default
#
# =============================================================================
# PORT CONFIGURATION
# =============================================================================
# PORT_ = port configuration: app|name|external:internal|access|protocol|login|traefik|webui|description
# - app: application name
# - name: service identifier (webui, dns, ssh, etc.)
# - external:internal: port mapping (external can be 'random' for auto-allocation)
# - access: 'public' (internet accessible), 'private' (local network only), 'disabled' (not running)
# - protocol: 'tcp' or 'udp'
# - login: if true, this port requires basic-auth via Traefik (only meaningful when traefik=true)
# - traefik: if true, Traefik handles this port (reverse proxy)
# - webui: if true, this port serves the main web interface
# - description: human-readable description of the service
#
# Two hosts, on purpose. Port 1 is the homeserver API on matrix.<domain>, which
# becomes server_name — so user IDs read @alice:matrix.<domain>. Port 2 is the
# Element web client on element.<domain>. Keeping them apart means Synapse can
# answer /.well-known/matrix/server for itself and federation needs no
# delegation from the apex domain, which this app has no way to configure.
#
# Port 1 must NOT be marked login=true: /_matrix is the client and federation
# API and basic-auth in front of it locks out every client and every peer.
#
CFG_MATRIX_PORT_1="matrix-synapse|homeserver|random:8008|public|tcp|false|true|false|Matrix Homeserver (client + federation API)||matrix"
CFG_MATRIX_PORT_2="matrix-element|webui|random:80|public|tcp|false|true|true|Element Web Interface||element"

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256" role="img" aria-label="Matrix"><rect width="256" height="256" rx="28" fill="#0dbd8b" /><path fill="#fff" d="M62 40h30v10h-8a6 6 0 0 0-6 6v144a6 6 0 0 0 6 6h8v10H62a10 10 0 0 1-10-10V50a10 10 0 0 1 10-10m132 0a10 10 0 0 1 10 10v156a10 10 0 0 1-10 10h-30v-10h8a6 6 0 0 0 6-6V56a6 6 0 0 0-6-6h-8V40z" /><path fill="#fff" d="M104 96h14v11h.4c4-8.4 12-12.6 20.6-12.6 9 0 16.6 3.8 20.6 12.6 4.8-8 13.4-12.6 22.4-12.6h.2v14.6c-1.4-.2-3-.4-4.4-.4-11.6 0-17.6 6.6-17.6 19.4V160h-15v-38.8c0-8.6-2.6-13.4-10-13.4-8.6 0-13.6 6-13.6 18.6V160h-15z" /></svg>

After

Width:  |  Height:  |  Size: 631 B

View File

@ -0,0 +1,24 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://ELEMENT_HOMESERVER_PLACEHOLDER",
"server_name": "ELEMENT_HOMESERVER_PLACEHOLDER"
}
},
"brand": "Element",
"disable_custom_urls": false,
"disable_guests": true,
"disable_login_language_selector": false,
"disable_3pid_login": false,
"default_country_code": "GB",
"show_labs_settings": true,
"room_directory": {
"servers": ["ELEMENT_HOMESERVER_PLACEHOLDER", "matrix.org"]
},
"setting_defaults": {
"breadcrumbs": true
},
"jitsi": {
"preferredDomain": "meet.element.io"
}
}

View File

@ -0,0 +1,103 @@
# Synapse homeserver configuration.
#
# Copied to <app dir>/data/homeserver.yaml by matrix_install_post_compose, which
# substitutes the *_PLACEHOLDER values below. Edit the deployed copy, not this
# template — this one is only read at install time.
#
# Restart the container after editing: docker restart matrix-synapse
# server_name is permanent. It is signed into every event this server has ever
# 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/"
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
listeners:
# Port 8008 is the container-internal port and is deliberately hardcoded: it
# is what CFG_MATRIX_PORT_1 declares as the internal half of its mapping, and
# what the Traefik service label points at. Change one and you must change all
# three.
- port: 8008
tls: false
type: http
# Traefik terminates TLS and proxies onward, so the source address Synapse
# sees is Traefik's. Without this, rate limiting and the audit log would
# attribute every request in the world to a single internal IP.
x_forwarded: true
bind_addresses: ['0.0.0.0']
resources:
- names: [client, federation]
compress: false
database:
name: psycopg2
args:
user: synapse
password: "SYNAPSE_DB_PASSWORD_PLACEHOLDER"
dbname: synapse
host: matrix-postgres
port: 5432
cp_min: 5
cp_max: 10
log_config: "/data/log.config"
media_store_path: /data/media_store
signing_key_path: "/data/signing.key"
# Uploads. Raise max_upload_size if your users share video; remember the
# reverse proxy has its own limit too.
max_upload_size: 50M
# Open registration is off by default: a reachable homeserver with registration
# enabled will be found and used for spam within days. The install creates one
# admin account for you; invite everyone else, or turn this on deliberately via
# CFG_MATRIX_ENABLE_REGISTRATION and re-run the install.
enable_registration: SYNAPSE_ENABLE_REGISTRATION_PLACEHOLDER
enable_registration_without_verification: SYNAPSE_ENABLE_REGISTRATION_PLACEHOLDER
registration_shared_secret: "SYNAPSE_REGISTRATION_SECRET_PLACEHOLDER"
macaroon_secret_key: "SYNAPSE_MACAROON_SECRET_PLACEHOLDER"
form_secret: "SYNAPSE_FORM_SECRET_PLACEHOLDER"
report_stats: false
suppress_key_server_warning: true
# Which servers to fetch other servers' signing keys from. matrix.org is the
# conventional default; federation still works if it is unreachable, just more
# slowly on first contact with a new server.
trusted_key_servers:
- server_name: "matrix.org"
# ---------------------------------------------------------------------------
# Single sign-on against Authelia (optional)
# ---------------------------------------------------------------------------
# CFG_MATRIX_AUTHELIA must stay false — that switch puts Authelia's forward-auth
# in front of /_matrix, which breaks every client. Real SSO is done here
# instead, with Synapse as an OIDC client of Authelia.
#
# Register the client in Authelia's configuration.yml first, then uncomment and
# fill in the block below and restart the container.
#
# oidc_providers:
# - idp_id: authelia
# idp_name: "Authelia"
# issuer: "https://auth.<your domain>"
# client_id: "synapse"
# client_secret: "<the secret you set in Authelia>"
# scopes: ["openid", "profile", "email"]
# user_mapping_provider:
# config:
# localpart_template: "{{ user.preferred_username }}"
# display_name_template: "{{ user.name }}"
# email_template: "{{ user.email }}"
#
# The redirect URI to register in Authelia is:
# https://SYNAPSE_SERVER_NAME_PLACEHOLDER/_synapse/client/oidc/callback

View File

@ -0,0 +1,32 @@
# Synapse logging configuration.
#
# Logs go to stdout only, so `docker logs matrix-synapse` and the LibrePortal
# log viewer both see them, and nothing accumulates inside the container that
# the host does not rotate.
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
# Set to INFO to log every database query — useful when chasing a slow
# server, far too noisy for normal running.
level: WARNING
# Very chatty at INFO, and rarely what you are looking for.
synapse.access.http.8008:
level: WARNING
root:
level: INFO
handlers: [console]
disable_existing_loggers: false

View File

@ -0,0 +1,223 @@
#!/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
}
# The homeserver's public host, read back out of the deployed compose after tag
# substitution has run. DOMAINSUBNAME_TAG_1 belongs to CFG_MATRIX_PORT_1 (the
# Synapse router), so this is the host that becomes server_name — deliberately
# not $host_setup, which for this two-host app points at Element instead.
_matrixServerName()
{
local app_name="$1"
tagsManagerGetTagContent "$containers_dir$app_name/docker-compose.yml" "DOMAINSUBNAME_TAG_1"
}
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 "$app_name")
if [[ -z "$server_name" ]]; then
isError "Could not determine the homeserver name from the compose file — aborting Synapse configuration."
isNotice "Check that CFG_MATRIX_PORT_1 is public and Traefik-managed, then reinstall."
return 1
fi
# Must match the password the compose handed to Postgres. Reading it back
# from the deployed compose is the only way to stay in step: the value is
# generated per-install by the password tag processor.
local db_password
db_password=$(tagsManagerGetTagContent "$app_dir/docker-compose.yml" "PASSWORD_TAG_1")
if [[ -z "$db_password" || "$db_password" == "PASSWORD_DATA_1" ]]; then
isError "Database password was not generated in the compose file — 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"
# 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}"
result=$(runFileOp docker run --rm \
-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.config" "data" | runInstallWrite -a "$logs_dir/$docker_log_file" 2>&1)
checkSuccess "Copying log.config 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_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"
# 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"
((menu_number++))
echo ""
echo "---- $menu_number. Creating the first Matrix admin account"
echo ""
# 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}"
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.
local result
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)
if [[ "$result" == *"already taken"* ]]; then
isNotice "Matrix admin '$admin_user' already exists — leaving the existing account alone."
else
checkSuccess "Creating Matrix admin account '$admin_user'"
fi
}
matrix_install_post()
{
local app_name="$1"
local server_name
server_name=$(_matrixServerName "$app_name")
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}"
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 ""
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 ""
}