Files
ess-docker-compose/quickstart.sh
T
wmair e2f578fde5 Fix MAS adminapi listener missing — Element Admin panel broken
Add `adminapi` resource to MAS HTTP listener in deploy.sh and quickstart.sh.
Without it, MAS never served /api/admin/v1/... causing Element Admin to always
throw TypeError: Failed to fetch. Updated docs and added regression test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 17:16:44 +01:00

503 lines
16 KiB
Bash
Executable File

#!/bin/bash
# Matrix Stack Quick Start
# Single-machine production deployment with Let's Encrypt.
# Asks three questions, generates all configs, starts the stack.
set -e
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; NC='\033[0m'
ok() { echo -e "${GREEN}${NC} $1"; }
info() { echo -e "${BLUE}·${NC} $1"; }
warn() { echo -e "${YELLOW}!${NC} $1"; }
fail() { echo -e "${RED}${NC} $1"; exit 1; }
gen_secret() { openssl rand -base64 32 | tr -d "=+/" | cut -c1-32; }
gen_hex() { openssl rand -hex 32; }
sudo docker ps &>/dev/null || fail "Cannot reach Docker. Is it running?"
echo ""
echo "Matrix Stack — Quick Start"
echo "=========================="
echo ""
# ── Three prompts ───────────────────────────────────────────────────────────
read -p "Domain (e.g. example.com): " DOMAIN
[[ -z "$DOMAIN" ]] && fail "Domain required."
read -p "Email for Let's Encrypt: " LETSENCRYPT_EMAIL
[[ -z "$LETSENCRYPT_EMAIL" ]] && fail "Email required."
read -p "Enable Element Call (video/voice)? [y/N]: " _EC
[[ "$_EC" =~ ^[Yy]$ ]] && USE_ELEMENT_CALL=true || USE_ELEMENT_CALL=false
echo ""
# ── Derived domains ─────────────────────────────────────────────────────────
MATRIX_DOMAIN="matrix.${DOMAIN}"
ELEMENT_DOMAIN="element.${DOMAIN}"
ADMIN_DOMAIN="admin.${DOMAIN}"
AUTH_DOMAIN="auth.${DOMAIN}"
CALL_DOMAIN="call.${DOMAIN}"
RTC_DOMAIN="rtc.${DOMAIN}"
# ── Check for existing data ──────────────────────────────────────────────────
if [[ -d "postgres/data" && "$(ls -A postgres/data 2>/dev/null)" ]] || \
[[ -f "synapse/data/homeserver.yaml" ]]; then
warn "Existing data found. quickstart.sh is for fresh installs."
warn "Continuing will wipe postgres/data, mas/data, and generated configs."
warn "Your synapse/data media store and bridge configs are preserved."
read -p "Wipe and continue? [y/N]: " _CONT
[[ "$_CONT" =~ ^[Yy]$ ]] || exit 0
sudo docker compose --profile single-machine --profile element-call down 2>/dev/null || true
sudo rm -rf postgres/data mas/data mas/certs caddy/data caddy/config
echo ""
fi
# ── Generate secrets ─────────────────────────────────────────────────────────
info "Generating secrets..."
POSTGRES_PASSWORD=$(gen_secret)
MAS_SECRET_KEY=$(gen_hex)
SYNAPSE_SHARED_SECRET=$(gen_secret)
SYNAPSE_CLIENT_SECRET=$(gen_secret)
$USE_ELEMENT_CALL && LIVEKIT_SECRET=$(gen_secret) || LIVEKIT_SECRET=""
ok "Secrets ready"
info "Generating MAS signing key..."
openssl genrsa 4096 2>/dev/null | openssl pkcs8 -topk8 -nocrypt > mas-signing.key 2>/dev/null
MAS_SIGNING_KEY=$(cat mas-signing.key)
ok "MAS signing key ready"
echo ""
# ── Directories ──────────────────────────────────────────────────────────────
mkdir -p postgres/{data,init,config}
mkdir -p synapse/data
mkdir -p mas/{config,data,certs}
mkdir -p element/config
mkdir -p caddy/{data,config}
mkdir -p livekit
mkdir -p bridges/{telegram,whatsapp,signal}/config
mkdir -p appservices
chmod 755 mas/config element/config
# ── .env ─────────────────────────────────────────────────────────────────────
info "Writing .env..."
cat > .env << EOF
# Generated by quickstart.sh — $(date -u +"%Y-%m-%d %H:%M UTC")
DOMAIN=${DOMAIN}
MATRIX_DOMAIN=${MATRIX_DOMAIN}
ELEMENT_DOMAIN=${ELEMENT_DOMAIN}
ADMIN_DOMAIN=${ADMIN_DOMAIN}
AUTH_DOMAIN=${AUTH_DOMAIN}
CALL_DOMAIN=${CALL_DOMAIN}
RTC_DOMAIN=${RTC_DOMAIN}
POSTGRES_USER=synapse
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_DB=synapse
MAS_DATABASE_URL=postgresql://synapse:${POSTGRES_PASSWORD}@postgres/mas
MAS_SECRET_KEY=${MAS_SECRET_KEY}
LIVEKIT_SECRET=${LIVEKIT_SECRET}
TZ=UTC
# Suppress docker compose warnings for optional Authelia vars (not used in quickstart)
AUTHELIA_JWT_SECRET=
AUTHELIA_SESSION_SECRET=
AUTHELIA_STORAGE_ENCRYPTION_KEY=
EOF
ok ".env written"
# ── MAS config ───────────────────────────────────────────────────────────────
info "Writing MAS config..."
cat > mas/config/config.yaml << EOF
---
http:
listeners:
- name: web
resources:
- name: discovery
- name: human
- name: oauth
- name: compat
- name: graphql
playground: true
- name: assets
- name: adminapi
binds:
- address: '[::]:8080'
- name: internal
resources:
- name: health
binds:
- address: '127.0.0.1:8081'
public_base: 'https://${AUTH_DOMAIN}/'
issuer: 'https://${AUTH_DOMAIN}/'
database:
uri: 'postgresql://synapse:${POSTGRES_PASSWORD}@postgres/mas'
auto_migrate: true
secrets:
encryption: '${MAS_SECRET_KEY}'
keys:
- kid: 'key-1'
algorithm: rs256
key: |
EOF
echo "$MAS_SIGNING_KEY" | sed 's/^/ /' >> mas/config/config.yaml
cat >> mas/config/config.yaml << EOF
matrix:
homeserver: '${MATRIX_DOMAIN}'
endpoint: 'http://synapse:8008'
secret: '${SYNAPSE_SHARED_SECRET}'
passwords:
enabled: true
email:
from: '"Matrix" <noreply@${DOMAIN}>'
transport: smtp
hostname: 'localhost'
port: 25
mode: plain
policy:
registration:
enabled: true
require_email: false
clients:
- client_id: '01HQW90Z35CMXFJWQPHC3BGZGQ'
client_auth_method: none
redirect_uris:
- 'https://${ELEMENT_DOMAIN}'
- 'https://${ELEMENT_DOMAIN}/mobile_guide/'
- 'io.element.app:/callback'
- client_id: '01ADMN00000000000000000000'
client_auth_method: none
redirect_uris:
- 'https://${ADMIN_DOMAIN}/'
- 'https://${ADMIN_DOMAIN}'
- client_id: '0000000000000000000SYNAPSE'
client_auth_method: client_secret_basic
client_secret: '${SYNAPSE_CLIENT_SECRET}'
EOF
ok "MAS config written"
# ── Element Web config ────────────────────────────────────────────────────────
info "Writing Element Web config..."
if $USE_ELEMENT_CALL; then
ELEMENT_CALL_FEATURES=',"feature_element_call_video_rooms": true'
ELEMENT_CALL_BLOCK=',
"element_call": {
"url": "https://'"${CALL_DOMAIN}"'",
"participant_limit": 8,
"brand": "Element Call"
}'
else
ELEMENT_CALL_FEATURES=''
ELEMENT_CALL_BLOCK=''
fi
cat > element/config/config.json << EOF
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://${MATRIX_DOMAIN}",
"server_name": "${MATRIX_DOMAIN}"
}
},
"brand": "Element",
"show_labs_settings": true,
"room_directory": {
"servers": ["${MATRIX_DOMAIN}"]
},
"features": {
"feature_oidc_aware_navigation": true${ELEMENT_CALL_FEATURES}
},
"default_server_name": "${MATRIX_DOMAIN}",
"disable_custom_urls": false,
"disable_guests": true${ELEMENT_CALL_BLOCK}
}
EOF
ok "Element Web config written"
# ── LiveKit config ────────────────────────────────────────────────────────────
if $USE_ELEMENT_CALL; then
info "Writing LiveKit config..."
cat > livekit/livekit.yaml << EOF
port: 7880
rtc:
tcp_port: 7881
port_range_start: 50100
port_range_end: 50200
use_external_ip: true
keys:
livekit-key: ${LIVEKIT_SECRET}
EOF
ok "LiveKit config written"
fi
# ── Synapse homeserver.yaml ───────────────────────────────────────────────────
if [[ ! -f "synapse/data/homeserver.yaml" ]]; then
info "Generating Synapse config..."
sudo docker run --rm \
-v "$(pwd)/synapse/data:/data" \
-e SYNAPSE_SERVER_NAME="${MATRIX_DOMAIN}" \
-e SYNAPSE_REPORT_STATS=no \
matrixdotorg/synapse:latest generate 2>/dev/null
ok "Synapse config generated"
fi
info "Patching Synapse config..."
# Remove sections that will be re-added fresh
sed -i '/^database:/,/^[^ ]/{ /^database:/d; /^[^ ]/!d }' synapse/data/homeserver.yaml
sed -i '/^matrix_authentication_service:/,/^[^ ]/{ /^matrix_authentication_service:/d; /^[^ ]/!d }' synapse/data/homeserver.yaml
sed -i '/^experimental_features:/,/^[^ ]/{ /^experimental_features:/d; /^[^ ]/!d }' synapse/data/homeserver.yaml
sed -i '/^enable_registration:/d' synapse/data/homeserver.yaml
sed -i '/^max_event_delay_duration:/d' synapse/data/homeserver.yaml
sed -i '/^rc_delayed_event_mgmt:/,/^[^ ]/{ /^rc_delayed_event_mgmt:/d; /^[^ ]/!d }' synapse/data/homeserver.yaml
cat >> synapse/data/homeserver.yaml << EOF
database:
name: psycopg2
args:
user: synapse
password: ${POSTGRES_PASSWORD}
database: synapse
host: postgres
port: 5432
cp_min: 5
cp_max: 10
enable_registration: false
matrix_authentication_service:
enabled: true
endpoint: 'http://mas:8080'
secret: '${SYNAPSE_SHARED_SECRET}'
EOF
if $USE_ELEMENT_CALL; then
cat >> synapse/data/homeserver.yaml << EOF
experimental_features:
msc3266_enabled: true
msc4222_enabled: true
msc4140_enabled: true
max_event_delay_duration: 24h
rc_delayed_event_mgmt:
per_second: 1
burst_count: 20
EOF
fi
ok "Synapse config patched"
# ── Caddyfile ─────────────────────────────────────────────────────────────────
info "Writing Caddyfile..."
if $USE_ELEMENT_CALL; then
WELLKNOWN_JSON="{\"m.homeserver\":{\"base_url\":\"https://${MATRIX_DOMAIN}\"},\"m.authentication\":{\"issuer\":\"https://${AUTH_DOMAIN}/\"},\"org.matrix.msc4143.rtc_foci\":[{\"type\":\"livekit\",\"livekit_service_url\":\"https://${RTC_DOMAIN}/livekit/jwt\"}]}"
ELEMENT_CFG_JSON="{\"default_server_config\":{\"m.homeserver\":{\"base_url\":\"https://${MATRIX_DOMAIN}\",\"server_name\":\"${MATRIX_DOMAIN}\"}},\"default_server_name\":\"${MATRIX_DOMAIN}\",\"disable_custom_urls\":false,\"disable_guests\":true,\"features\":{\"feature_oidc_aware_navigation\":true,\"feature_element_call_video_rooms\":true},\"element_call\":{\"url\":\"https://${CALL_DOMAIN}\",\"participant_limit\":8,\"brand\":\"Element Call\"}}"
else
WELLKNOWN_JSON="{\"m.homeserver\":{\"base_url\":\"https://${MATRIX_DOMAIN}\"},\"m.authentication\":{\"issuer\":\"https://${AUTH_DOMAIN}/\"}}"
ELEMENT_CFG_JSON="{\"default_server_config\":{\"m.homeserver\":{\"base_url\":\"https://${MATRIX_DOMAIN}\",\"server_name\":\"${MATRIX_DOMAIN}\"}},\"default_server_name\":\"${MATRIX_DOMAIN}\",\"disable_custom_urls\":false,\"disable_guests\":true,\"features\":{\"feature_oidc_aware_navigation\":true}}"
fi
cat > caddy/Caddyfile << EOF
{
email ${LETSENCRYPT_EMAIL}
admin 0.0.0.0:2019
}
${MATRIX_DOMAIN} {
@wk path /.well-known/matrix/client
handle @wk {
header Content-Type application/json
header Access-Control-Allow-Origin "*"
respond \`${WELLKNOWN_JSON}\` 200
}
@wk_server path /.well-known/matrix/server
handle @wk_server {
header Content-Type application/json
respond \`{"m.server":"${MATRIX_DOMAIN}:443"}\` 200
}
@preflight {
method OPTIONS
path_regexp ^/_matrix/.*\$
}
handle @preflight {
header Access-Control-Allow-Origin "*"
header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
header Access-Control-Allow-Headers "Authorization, Content-Type, Accept"
header Access-Control-Max-Age "86400"
respond 204
}
@compat path /_matrix/client/v3/login* /_matrix/client/v3/logout* /_matrix/client/v3/refresh* /_matrix/client/r0/login* /_matrix/client/r0/logout* /_matrix/client/r0/refresh*
handle @compat {
header Access-Control-Allow-Origin "*"
reverse_proxy mas:8080 {
header_down -Access-Control-Allow-Origin
}
}
@matrix path_regexp ^/_matrix/.*\$
handle @matrix {
header Access-Control-Allow-Origin "*"
reverse_proxy synapse:8008 {
header_down -Access-Control-Allow-Origin
}
}
handle {
reverse_proxy synapse:8008
}
}
${AUTH_DOMAIN} {
@disco path /.well-known/openid-configuration
handle @disco {
header Access-Control-Allow-Origin "*"
reverse_proxy mas:8080
}
@oauth path /oauth2/*
route @oauth {
header Access-Control-Allow-Origin "*"
reverse_proxy mas:8080
}
handle_path /account/* {
reverse_proxy mas:8080
}
handle {
reverse_proxy mas:8080
}
}
${ELEMENT_DOMAIN} {
@cfg path /config.json
handle @cfg {
header Content-Type application/json
header Cache-Control no-store
respond \`${ELEMENT_CFG_JSON}\` 200
}
handle {
reverse_proxy element:80
}
}
${ADMIN_DOMAIN} {
reverse_proxy element-admin:8080
}
EOF
if $USE_ELEMENT_CALL; then
cat >> caddy/Caddyfile << EOF
${RTC_DOMAIN} {
handle_path /livekit/jwt* {
reverse_proxy lk-jwt-service:8080
}
handle_path /livekit/sfu* {
reverse_proxy livekit:7880
}
}
${CALL_DOMAIN} {
reverse_proxy element-call:8080
}
EOF
fi
ok "Caddyfile written"
echo ""
# ── Start the stack ───────────────────────────────────────────────────────────
info "Starting PostgreSQL..."
sudo docker compose up -d postgres
info "Waiting for PostgreSQL..."
for i in {1..30}; do
sudo docker compose exec -T postgres pg_isready -U synapse &>/dev/null && break
[[ $i -eq 30 ]] && fail "PostgreSQL did not become ready in time"
sleep 2
done
ok "PostgreSQL ready"
CORE_SERVICES="postgres synapse mas element element-admin caddy"
if $USE_ELEMENT_CALL; then CORE_SERVICES="${CORE_SERVICES} livekit lk-jwt-service element-call"; fi
info "Starting all services..."
sudo docker compose --profile single-machine up -d ${CORE_SERVICES}
echo ""
# ── Summary ───────────────────────────────────────────────────────────────────
ok "Stack is up."
echo ""
echo " Element Web: https://${ELEMENT_DOMAIN}"
echo " Matrix API: https://${MATRIX_DOMAIN}"
echo " MAS Auth: https://${AUTH_DOMAIN}"
echo " Element Admin: https://${ADMIN_DOMAIN}"
if $USE_ELEMENT_CALL; then echo " Element Call: https://${CALL_DOMAIN}"; fi
echo ""
echo "DNS — point all subdomains to this server:"
echo " ${MATRIX_DOMAIN}"
echo " ${ELEMENT_DOMAIN}"
echo " ${ADMIN_DOMAIN}"
echo " ${AUTH_DOMAIN}"
if $USE_ELEMENT_CALL; then
echo " ${CALL_DOMAIN}"
echo " ${RTC_DOMAIN}"
echo ""
echo "Firewall — open in addition to 80/443:"
echo " TCP 7881 (LiveKit WebRTC signaling)"
echo " UDP 50100-50200 (LiveKit media)"
fi
echo "Register your first account at https://${ELEMENT_DOMAIN}"
echo ""
echo "To set up messaging bridges (WhatsApp, Signal, Telegram):"
echo " ./setup-bridges.sh"
echo ""
echo "Logs: docker compose logs -f"
echo "Stop: docker compose --profile single-machine down"
echo ""