#!/bin/sh set -eu ESCALATE=$(command -v sudo || command -v doas) || { echo "[ERROR] Neither sudo nor doas found. Cannot run as root." >&2 exit 1 } [ "$(id -u)" -eq 0 ] || exec $ESCALATE "$0" "$@" BASE_DIR="/opt/matrix" DOCKER_COMPOSE=$(command -v docker-compose || command -v docker compose) || { echo "[ERROR] docker-compose or docker compose not found" >&2 exit 1 } usage() { cat <<'EOF' whiterabbit: auto-configure [matrix] Homeservers with a global Coturn instance and Nginx + Let's Encrypt proxy. Usage: wh turn wh dendrite wh tuwunel wh list wh remove [domain2 ...] EOF exit 1 } require_nonempty() { VAR_NAME="$1" VAR_VALUE="$2" [ -n "$VAR_VALUE" ] || { echo "[ERROR] $VAR_NAME cannot be empty" >&2 exit 1 } } list_servers() { echo "=== Installed servers ===" for dir in "$BASE_DIR"/*; do [ -d "$dir" ] || continue name=$(basename "$dir") container_info=$(docker ps --format "{{.Names}} {{.Status}} {{.Ports}}" | grep "^${name}") || container_info="" [ -n "$container_info" ] && { echo "- $name [RUNNING] on:" echo "$container_info" | while read line; do cname=$(echo "$line" | awk '{print $1}') cstatus=$(echo "$line" | awk '{print $2}') cports=$(echo "$line" | awk '{$1=$2=""; print $0}' | sed 's/^ //') echo " $cname : $cstatus, ports: $cports" done } || echo "- $name [STOPPED]" done } install_turn() { TURN_DOMAIN="$1" TURN_SERVER_IP="$2" TURN_LISTENING_DEVICE="$3" TURN_SECRET="$4" require_nonempty TURN_DOMAIN "$TURN_DOMAIN" require_nonempty TURN_SERVER_IP "$TURN_SERVER_IP" require_nonempty TURN_LISTENING_DEVICE "$TURN_LISTENING_DEVICE" require_nonempty TURN_SECRET "$TURN_SECRET" echo "=== Installing global Coturn ($TURN_DOMAIN) ===" mkdir -p /etc/turn cat < /etc/turnserver.conf listening-device=$TURN_LISTENING_DEVICE listening-port=3478 tls-listening-port=5349 listening-ip=$TURN_SERVER_IP min-port=49152 max-port=65535 use-auth-secret static-auth-secret=$TURN_SECRET realm=$TURN_DOMAIN syslog no-rfc5780 no-stun-backward-compatibility response-origin-only-with-rfc5780 EOF echo "$TURN_DOMAIN" > /etc/turn/domain echo "$TURN_SECRET" > /etc/turn/secret case $(command -v systemctl >/dev/null && echo y || echo n) in y) systemctl enable --now coturn || true ;; n) service coturn start || true ;; esac echo "[OK] Global Coturn configured ($TURN_DOMAIN)" } common_prompts() { MATRIX_DOMAIN="$1" EMAIL="$2" MATRIX_SERVER_IP="$3" require_nonempty MATRIX_DOMAIN "$MATRIX_DOMAIN" require_nonempty EMAIL "$EMAIL" require_nonempty MATRIX_SERVER_IP "$MATRIX_SERVER_IP" DB_PASS=$(tr -dc 'A-Za-z0-9' "/etc/nginx/sites-available/$DOMAIN" server { listen 80; server_name $DOMAIN; location /.well-known/matrix/server { default_type application/json; return 200 '{ "m.server": "$DOMAIN:443" }'; } location /.well-known/matrix/client { default_type application/json; return 200 '{ "m.homeserver": { "base_url": "https://$DOMAIN" }, "m.identity_server": { "base_url": "https://vector.im" } }'; } location / { proxy_pass http://127.0.0.1:$SERVICE_PORT; proxy_set_header Host \$host; proxy_set_header X-Forwarded-For \$remote_addr; } } EOF ln -sf "/etc/nginx/sites-available/$DOMAIN" "/etc/nginx/sites-enabled/$DOMAIN" nginx -t || { echo "[ERROR] Invalid nginx config"; exit 1; } case $(command -v systemctl >/dev/null && echo y || echo n) in y) systemctl reload nginx ;; n) nginx -s reload ;; esac case $(command -v certbot >/dev/null && echo y || echo n) in y) certbot --nginx --non-interactive --agree-tos -m "$EMAIL" -d "$DOMAIN" || { echo "[WARN] Certbot failed for $DOMAIN" } ;; n) echo "[WARN] Certbot not installed, skipping TLS setup" ;; esac } install_tuwunel() { echo "=== Installing Tuwunel ===" common_prompts "$@" INSTALL_DIR="$BASE_DIR/$MATRIX_DOMAIN" mkdir -p "$INSTALL_DIR/config" cd "$INSTALL_DIR" || exit 1 cat < .env TUWUNEL_SERVER_NAME=$MATRIX_DOMAIN TUWUNEL_DATABASE_PATH=/var/lib/tuwunel TUWUNEL_PORT=6167 TUWUNEL_MAX_REQUEST_SIZE=20000000 TUWUNEL_ALLOW_REGISTRATION=false TUWUNEL_REGISTRATION_TOKEN=$REG_SECRET TUWUNEL_ALLOW_ENCRYPTION=true TUWUNEL_ALLOW_FEDERATION=true TUWUNEL_TRUSTED_SERVERS='["matrix.org", "matrix.philw.dev"]' TUWUNEL_ADDRESS=0.0.0.0 TUWUNEL_TURN_SECRET="$TURN_SECRET" TUWUNEL_TURN_URIS=["turn:$TURN_DOMAIN?transport=udp", "turn:$TURN_DOMAIN?transport=tcp"] TUWUNEL_NEW_USER_DISPLAYNAME_SUFFIX="" EOF cat < compose.yml services: homeserver: image: jevolk/tuwunel:latest container_name: tuw.philw.dev-tuwunel restart: unless-stopped ports: - "$PORT_HTTP:6167" volumes: - db:/var/lib/tuwunel env_file: - .env volumes: db: EOF $DOCKER_COMPOSE up -d configure_nginx "$MATRIX_DOMAIN" "$PORT_HTTP" echo "[OK] Tuwunel server ($MATRIX_DOMAIN) is running." } install_dendrite() { echo "=== Installing Dendrite ===" common_prompts "$@" INSTALL_DIR="$BASE_DIR/$MATRIX_DOMAIN" mkdir -p "$INSTALL_DIR/config" cd "$INSTALL_DIR" || exit 1 cat < .env POSTGRES_HOSTNAME=postgres POSTGRES_VERSION=15-alpine POSTGRES_USER=dendrite POSTGRES_PASSWORD=$DB_PASS POSTGRES_DB=dendrite MONOLITH_HOSTNAME=monolith MONOLITH_IMAGE=matrixdotorg/dendrite-monolith:latest MONOLITH_PORT_HTTP=$PORT_HTTP MONOLITH_PORT_HTTPS=$PORT_HTTPS EOF cat < compose.yml services: postgres: hostname: \${POSTGRES_HOSTNAME:-postgres} container_name: ${MATRIX_DOMAIN}-db image: postgres:\${POSTGRES_VERSION:-15-alpine} restart: always volumes: - ./dendrite_postgres_data:/var/lib/postgresql/data environment: POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:-$DB_PASS} POSTGRES_USER: \${POSTGRES_USER:-dendrite} POSTGRES_DB: \${POSTGRES_DB:-dendrite} healthcheck: test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER:-dendrite}"] interval: 5s timeout: 5s retries: 5 networks: - internal monolith: hostname: \${MONOLITH_HOSTNAME:-monolith} container_name: ${MATRIX_DOMAIN} image: \${MONOLITH_IMAGE:-matrixdotorg/dendrite-monolith:latest} ports: - \${MONOLITH_PORT_HTTP:-8008}:8008 - \${MONOLITH_PORT_HTTPS:-8448}:8448 volumes: - ./config:/etc/dendrite - ./dendrite_media:/var/dendrite/media - ./dendrite_jetstream:/var/dendrite/jetstream - ./dendrite_search_index:/var/dendrite/searchindex - ./:/mnt depends_on: postgres: condition: service_healthy networks: - internal restart: unless-stopped networks: internal: attachable: true volumes: dendrite_postgres_data: dendrite_media: dendrite_jetstream: dendrite_search_index: EOF cat < config/dendrite.yaml version: 2 global: server_name: $MATRIX_DOMAIN private_key: /mnt/matrix_key.pem tls_cert: /mnt/server.crt tls_key: /mnt/server.key old_private_keys: [] key_validity_period: 168h0m0s database: connection_string: postgresql://dendrite:$DB_PASS@postgres/dendrite?sslmode=disable max_open_conns: 90 max_idle_conns: 5 conn_max_lifetime: -1 well_known_server_name: "$MATRIX_DOMAIN:443" well_known_client_name: "https://$MATRIX_DOMAIN" cache: max_size_estimated: 1gb max_age: 1h trusted_third_party_id_servers: - matrix.org - vector.im disable_federation: false report_stats: enabled: false client_api: registration_disabled: true registration_shared_secret: "$REG_SECRET" turn: turn_user_lifetime: "5m" turn_uris: - turn:$TURN_DOMAIN?transport=udp - turn:$TURN_DOMAIN?transport=tcp turn_shared_secret: "$TURN_SECRET" federation_api: send_max_retries: 16 disable_tls_validation: false media_api: base_path: ./media_store max_file_size_bytes: 10485760 sync_api: search: enabled: false index_path: "./searchindex" user_api: auto_join_rooms: - "#main:$MATRIX_DOMAIN" logging: - type: std level: info - type: file level: info params: path: ./logs jetstream: addresses: [] disable_tls_validation: false storage_path: ./ topic_prefix: Dendrite metrics: enabled: false basic_auth: username: metrics password: metrics dns_cache: enabled: false cache_size: 256 cache_lifetime: "5m" EOF echo "[INFO] Generating Dendrite keys..." docker run --rm --entrypoint="" -v "$(pwd)":/mnt matrixdotorg/dendrite-monolith:latest \ /usr/bin/generate-keys -private-key /mnt/matrix_key.pem -tls-cert /mnt/server.crt -tls-key /mnt/server.key echo "[OK] Keys generated in $PWD" $DOCKER_COMPOSE up -d configure_nginx "$MATRIX_DOMAIN" "$PORT_HTTP" echo "[OK] Dendrite server ($MATRIX_DOMAIN) is running." } # @TODO STILL BROKEN # install_synapse() { # echo "=== Installing Synapse ===" # MATRIX_DOMAIN="$1" # EMAIL="$2" # MATRIX_SERVER_IP="$3" # # require_nonempty MATRIX_DOMAIN "$MATRIX_DOMAIN" # require_nonempty EMAIL "$EMAIL" # require_nonempty MATRIX_SERVER_IP "$MATRIX_SERVER_IP" # # INSTALL_DIR="$BASE_DIR/$MATRIX_DOMAIN" # mkdir -p "$INSTALL_DIR" # cd "$INSTALL_DIR" || exit 1 # mkdir -p data db # # DB_USER="synapse" # DB_NAME="synapse" # DB_PASS=$(openssl rand -hex 16) # REG_SECRET=$(openssl rand -hex 32) # if command -v docker-compose >/dev/null 2>&1; then # DOCKER_COMPOSE_CMD="docker-compose" # elif command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then # DOCKER_COMPOSE_CMD="docker compose" # else # echo "[ERROR] Neither docker-compose nor docker compose available" >&2 # exit 1 # fi # # PORT_HTTP=${PORT_HTTP:-8008} # PORT_HTTPS=${PORT_HTTPS:-8448} # TURN_DOMAIN=$(cat /etc/turn/domain 2>/dev/null || true) # TURN_SECRET=$(cat /etc/turn/secret 2>/dev/null || true) # cat > .env < compose.yml < data/homeserver.yaml < [domain2 ...]" && return 1 for DOMAIN in "$@"; do SERVER_DIR="$BASE_DIR/$DOMAIN" case "$DOMAIN" in ""|"/"|".") echo "[ERROR] Refusing to delete dangerous path ($DOMAIN)" continue ;; esac [ -d "$SERVER_DIR" ] && \ echo "[INFO] Stopping and removing server: $DOMAIN" && \ (cd "$SERVER_DIR" && $DOCKER_COMPOSE down -v) || \ echo "[WARN] Failed to stop container for $DOMAIN" rm -rf "$SERVER_DIR" rm -f "/etc/nginx/sites-available/$DOMAIN" "/etc/nginx/sites-enabled/$DOMAIN" done nginx -t && (systemctl reload nginx 2>/dev/null || nginx -s reload) echo "[OK] Done removing servers: $*" } CMD="${1:-}" case "$CMD" in turn) shift; install_turn "$@" ;; dendrite) shift; install_dendrite "$@" ;; # synapse) shift; install_synapse "$@" ;; tuwunel) shift; install_tuwunel "$@" ;; list) list_servers ;; remove) shift; remove_server "$@" ;; *) usage ;; esac