From 46f1bded3923c1c64b625f2c8f47a4dd6ad101f7 Mon Sep 17 00:00:00 2001 From: samjage Date: Sun, 5 Apr 2026 23:30:22 -0400 Subject: [PATCH] first pass --- .env.example | 58 +++++++ .gitignore | 5 + README.md | 305 +++++++++++++++++++++++++++++++++ coturn/turnserver.conf | 21 +++ docker-compose.yml | 65 +++++++ livekit/livekit.yaml | 8 + scripts/check-image-updates.sh | 35 ++++ scripts/rotate-secrets.sh | 78 +++++++++ tuwunel/tuwunel.toml | 21 +++ 9 files changed, 596 insertions(+) create mode 100644 .env.example create mode 100755 .gitignore create mode 100644 README.md create mode 100755 coturn/turnserver.conf create mode 100755 docker-compose.yml create mode 100755 livekit/livekit.yaml create mode 100644 scripts/check-image-updates.sh create mode 100644 scripts/rotate-secrets.sh create mode 100755 tuwunel/tuwunel.toml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c00bb4d --- /dev/null +++ b/.env.example @@ -0,0 +1,58 @@ +# ============================================================ +# Matrix Stack — Environment Configuration +# Copy this file to .env and fill in all values +# cp .env.example .env +# ============================================================ + + +# ------------------------------------------------------------ +# DOMAIN +# Your base domain. matrix.DOMAIN_NAME and livekit.DOMAIN_NAME +# will be derived from this automatically. +# ------------------------------------------------------------ +DOMAIN_NAME= + + +# ------------------------------------------------------------ +# SERVER +# The public IPv4 address of this server. +# Used by LiveKit and coturn to advertise themselves externally. +# curl -4 ifconfig.me +# ------------------------------------------------------------ +EXTERNAL_IP= + + +# ------------------------------------------------------------ +# TURN SECRET +# Shared secret between coturn and tuwunel for TURN auth. +# Must match on both sides — do not set them separately. +# openssl rand -hex 32 +# ------------------------------------------------------------ +STATIC_AUTH_SECRET= + + +# ------------------------------------------------------------ +# LIVEKIT API CREDENTIALS +# Key/secret pair for LiveKit. Used by the server, lk-jwt-service, +# and must match the LIVEKIT_KEYS entry in livekit.yaml if set there. +# API_KEY: openssl rand -hex 16 +# API_SECRET: openssl rand -hex 32 +# ------------------------------------------------------------ +API_KEY= +API_SECRET= + + +# ------------------------------------------------------------ +# DOCKER NETWORK +# The external Docker network shared across your stacks. +# Create it first if it doesn't exist: docker network create +# ------------------------------------------------------------ +NETWORK_NAME=backbone + + +# ------------------------------------------------------------ +# IMAGE TAG +# Docker image tag to deploy. Use 'latest' for development. +# Set to a specific SHA or version tag for production stability. +# ------------------------------------------------------------ +GIT_SHA=latest diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..d265d8d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +coturn/certs/ +*.pem +*.key +rotation.log \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f4087e --- /dev/null +++ b/README.md @@ -0,0 +1,305 @@ +# 🧩 Matrix Stack + +> Self-hosted Matrix homeserver with native voice & video calling, TURN relay, and LiveKit WebRTC integration. + +Built on **[Tuwunel](https://github.com/jevolk/tuwunel)** — a fast, modern Matrix homeserver — with full [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143) support for in-room calls via LiveKit. + +--- + +## 📦 Services + +| Container | Image | Role | +|---|---|---| +| `tuwunel` | `jevolk/tuwunel` | Matrix homeserver | +| `livekit` | `livekit/livekit-server` | WebRTC SFU — voice & video | +| `lk-jwt-service` | `ghcr.io/element-hq/lk-jwt-service` | Issues LiveKit tokens to Matrix clients | +| `coturn` | `coturn/coturn` | TURN/STUN relay for NAT traversal | + +--- + +## ⚙️ Configuration + +### 🔑 Environment variables + +Copy `.env.example` to `.env` and fill in all values before starting the stack. + +```bash +cp .env.example .env +``` + +| Variable | Description | +|---|---| +| `DOMAIN_NAME` | Your base domain — e.g. `example.com` | +| `EXTERNAL_IP` | Your server's public IPv4 address | +| `STATIC_AUTH_SECRET` | Shared TURN secret between coturn and tuwunel | +| `API_KEY` | LiveKit API key | +| `API_SECRET` | LiveKit API secret | +| `NETWORK_NAME` | External Docker network shared across stacks — default `backbone` | +| `GIT_SHA` | Image tag to deploy — use `latest` for development | + +**Generating secrets:** + +| Variable | Command | +|---|---| +| `STATIC_AUTH_SECRET` | `openssl rand -hex 32` | +| `API_SECRET` | `openssl rand -hex 32` | +| `API_KEY` | `openssl rand -hex 16` | +| `EXTERNAL_IP` | `curl -4 ifconfig.me` | + +> ⚠️ `.env` is gitignored and must **never** be committed. + +--- + +### 🔁 Secret rotation + +`scripts/rotate-secrets.sh` regenerates secrets in `.env` and restarts only the affected containers. + +```bash +bash scripts/rotate-secrets.sh # rotate everything +bash scripts/rotate-secrets.sh --turn # TURN secret only (restarts coturn + tuwunel) +bash scripts/rotate-secrets.sh --livekit # LiveKit keys only (restarts livekit + lk-jwt-service) +``` + +> ⚠️ Active calls and sessions will be dropped on rotation. Run during a maintenance window. + +Rotation events are logged to `scripts/rotation.log` (gitignored). + +**To run on a schedule** — add a cron entry on the host: + +```bash +crontab -e +``` + +```cron +# Rotate all Matrix secrets every 90 days at 3am +0 3 1 */3 * cd /opt/stacks/matrix && bash scripts/rotate-secrets.sh --all >> /var/log/matrix-rotation.log 2>&1 +``` + +--- + +### 📝 Variable substitution — what works where + +Docker Compose substitutes `${VAR}` **only inside `docker-compose.yml`**. Config files mounted as volumes are read as plain text — variables are not expanded inside them. + +| File | Substitution | How secrets get in | +|---|---|---| +| `docker-compose.yml` | ✅ Yes | Directly from `.env` | +| `tuwunel/tuwunel.toml` | ❌ No | `server_name` is a required field — update it to your domain. `TUWUNEL_SERVER_NAME` in compose overrides it at runtime but the field must be present | +| `coturn/turnserver.conf` | ❌ No | Secret and realm passed as CLI args in compose | +| `livekit/livekit.yaml` | ❌ No | API keys passed via `LIVEKIT_KEYS` env var in compose | + +--- + +## 🚀 Deployment + +### Prerequisites + +Both of these must exist before the stack will start — Docker will refuse to create containers otherwise. + +**1. Create your Docker network** (set `NETWORK_NAME` in `.env` to match): +```bash +docker network create backbone +``` + +**2. Create the tuwunel data volume:** +```bash +docker volume create matrix_tuwunel-data +``` + +### Start the stack + +```bash +docker compose up -d +``` + +--- + +## 🔀 Reverse Proxy + +This stack does not run its own Caddy instance. Routing is handled by a shared Caddy container on the `backbone` Docker network. Add the following blocks to your Caddyfile. + +> 💡 **Always use `handle` blocks.** Do not mix bare `respond` directives with a `handle {}` catch-all in the same site block — Caddy's `handle {}` will intercept all requests before a bare `respond` can fire. + +--- + +### 🌐 Base domain — client & server discovery + +Serves the `.well-known/matrix` endpoints that tell clients where the homeserver and LiveKit service are. Required for login and calling to work. + +```caddy +your.domain { + handle /.well-known/matrix/server { + header Content-Type application/json + header Access-Control-Allow-Origin * + respond `{"m.server":"matrix.your.domain:443"}` + } + handle /.well-known/matrix/client { + header Content-Type application/json + header Access-Control-Allow-Origin * + respond `{ + "m.homeserver": { + "base_url": "https://matrix.your.domain" + }, + "org.matrix.msc4143.rtc_foci": [ + { + "type": "livekit", + "livekit_service_url": "https://livekit.your.domain" + } + ] +}` + } + handle { + respond 404 + } +} +``` + +--- + +### 🏠 Matrix homeserver + +Routes Matrix API traffic to tuwunel. WebSocket headers are required for client sync connections. + +```caddy +matrix.your.domain { + handle /_version { + respond `{"sha":"{env.GIT_SHA}"}` + } + handle /_matrix/* { + reverse_proxy tuwunel:6167 { + header_up Upgrade {http.request.header.Upgrade} + header_up Connection {http.request.header.Connection} + } + } + handle /_synapse/* { + reverse_proxy tuwunel:6167 { + header_up Upgrade {http.request.header.Upgrade} + header_up Connection {http.request.header.Connection} + } + } + handle { + respond 404 + } +} +``` + +--- + +### 🔗 Federation — port 8448 + +Handles server-to-server federation traffic. Requires port `8448` exposed on your Caddy container. + +```caddy +matrix.your.domain:8448 { + reverse_proxy tuwunel:6167 +} +``` + +```yaml +# caddy docker-compose.yml +ports: + - "8448:8448" +``` + +--- + +### 📹 LiveKit + +Routes JWT token requests to `lk-jwt-service` and all WebRTC traffic to the LiveKit server. + +LiveKit runs with `network_mode: host` and is **not** on the Docker network — it is reached via `host.docker.internal`. This requires the following in your Caddy compose: + +```yaml +extra_hosts: + - "host.docker.internal:host-gateway" +``` + +```caddy +livekit.your.domain { + handle /sfu/* { + reverse_proxy lk-jwt-service:8080 + } + handle /livekit/jwt* { + reverse_proxy lk-jwt-service:8080 + } + handle { + reverse_proxy host.docker.internal:7880 { + header_up Upgrade {http.request.header.Upgrade} + header_up Connection {http.request.header.Connection} + } + } +} +``` + +> ⚠️ WebSocket headers on the LiveKit catch-all are **required**. Omitting them causes `MISSING_MATRIX_RTC_TRANSPORT` errors on clients. + +--- + +## 📋 Network topology + +``` + ┌─────────────────────────────┐ + │ backbone network │ + │ │ + Client ──HTTPS──▶ │ Caddy ──▶ tuwunel:6167 │ + │ ──▶ lk-jwt-service │ + │ ──▶ host.docker.internal:7880 (livekit) + └─────────────────────────────┘ + + host network (UDP): livekit (ports 7880, 7881, 50000-60000) + coturn (ports 3478, 5349, 49152-65535) +``` + +--- + +## 📁 Structure + +``` +. +├── tuwunel/ +│ └── tuwunel.toml # Homeserver config — hardcode server_name here +├── livekit/ +│ └── livekit.yaml # LiveKit ports and RTC config +├── coturn/ +│ ├── turnserver.conf # TURN config — no secrets, those go in compose +│ └── certs/ # Drop fullchain.pem + privkey.pem here to enable TURNS (gitignored) +├── docker-compose.yml +├── .env # Secrets — gitignored, never commit +└── .env.example # Template — safe to commit +``` + +--- + +## 🔒 Security notes + +- `coturn` blocks RFC1918 ranges in `turnserver.conf` to prevent TURN relay abuse +- Matrix registration is disabled by default in `tuwunel/tuwunel.toml` + +--- + +## 🔐 Enabling TURNS (TURN over TLS) + +TURNS encrypts relay traffic on port `5349`. The stack is pre-wired for it — `coturn/certs/` is already mounted into the container and `TUWUNEL_TURN_URIS` already advertises the `turns:` URI to clients. It just needs certs and the config uncommented. + +**To enable:** + +1. Drop your certificates into `coturn/certs/`: + ``` + coturn/certs/fullchain.pem + coturn/certs/privkey.pem + ``` + Certs must be valid for `turn.DOMAIN_NAME`. If you have a wildcard cert (`*.yourdomain.com`) it will work as-is. + +2. Uncomment the TLS lines in `coturn/turnserver.conf`: + ``` + tls-listening-port=5349 + cert=/etc/coturn/certs/fullchain.pem + pkey=/etc/coturn/certs/privkey.pem + ``` + +3. Restart coturn: + ```bash + docker compose up -d coturn + ``` + +> `coturn/certs/` is gitignored — your private key will never be committed. diff --git a/coturn/turnserver.conf b/coturn/turnserver.conf new file mode 100755 index 0000000..a7798c5 --- /dev/null +++ b/coturn/turnserver.conf @@ -0,0 +1,21 @@ +use-auth-secret + +listening-port=3478 +# tls-listening-port=5349 +# cert=/etc/coturn/certs/fullchain.pem +# pkey=/etc/coturn/certs/privkey.pem + +min-port=49152 +max-port=65535 + +verbose +fingerprint +no-multicast-peers + +# Block RFC1918 ranges to prevent TURN relay abuse +denied-peer-ip=10.0.0.0-10.255.255.255 +denied-peer-ip=192.168.0.0-192.168.255.255 +denied-peer-ip=172.16.0.0-172.31.255.255 + +# static-auth-secret and realm are passed via CLI args in docker-compose.yml +# Do not put secrets in this file — it does not support variable substitution diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..a0a3a0c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +services: + tuwunel: + image: jevolk/tuwunel:latest + container_name: tuwunel + restart: unless-stopped + networks: + - backbone + environment: + TUWUNEL_CONFIG: /etc/tuwunel/tuwunel.toml + TUWUNEL_SERVER_NAME: ${DOMAIN_NAME} + TUWUNEL_TURN_URIS: '["turn:turn.${DOMAIN_NAME}:3478?transport=udp","turn:turn.${DOMAIN_NAME}:3478?transport=tcp","turns:turn.${DOMAIN_NAME}:5349?transport=tcp"]' + TUWUNEL_TURN_SECRET: ${STATIC_AUTH_SECRET} + volumes: + - matrix_tuwunel-data:/var/lib/tuwunel + - ./tuwunel/tuwunel.toml:/etc/tuwunel/tuwunel.toml:ro + coturn: + image: coturn/coturn:latest + container_name: coturn + restart: unless-stopped + network_mode: host + volumes: + - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro + - ./coturn/certs:/etc/coturn/certs:ro + command: + - -c + - /etc/coturn/turnserver.conf + - --static-auth-secret=${STATIC_AUTH_SECRET} + - --external-ip=${EXTERNAL_IP} + - --realm=turn.${DOMAIN_NAME} + + livekit: + image: livekit/livekit-server:latest + container_name: livekit + restart: unless-stopped + network_mode: host + volumes: + - ./livekit/livekit.yaml:/etc/livekit.yaml:ro + command: + - --config + - /etc/livekit.yaml + - --node-ip=${EXTERNAL_IP} + environment: + LIVEKIT_KEYS: "${API_KEY}: ${API_SECRET}" + + lk-jwt-service: + image: ghcr.io/element-hq/lk-jwt-service:latest + container_name: lk-jwt-service + restart: unless-stopped + environment: + LIVEKIT_URL: wss://livekit.${DOMAIN_NAME} + LIVEKIT_KEY: ${API_KEY} + LIVEKIT_SECRET: ${API_SECRET} + LIVEKIT_FULL_ACCESS_HOMESERVERS: ${DOMAIN_NAME} + networks: + - backbone + +volumes: + matrix_tuwunel-data: + external: true + name: matrix_tuwunel-data + +networks: + backbone: + external: true + name: ${NETWORK_NAME} diff --git a/livekit/livekit.yaml b/livekit/livekit.yaml new file mode 100755 index 0000000..a5d9ef8 --- /dev/null +++ b/livekit/livekit.yaml @@ -0,0 +1,8 @@ +port: 7880 +rtc: + tcp_port: 7881 + port_range_start: 50000 + port_range_end: 60000 + use_external_ip: true +logging: + level: info diff --git a/scripts/check-image-updates.sh b/scripts/check-image-updates.sh new file mode 100644 index 0000000..4ec4452 --- /dev/null +++ b/scripts/check-image-updates.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# ============================================================= +# check-image-updates.sh +# Checks all running containers for newer images and caches +# results to /tmp/.docker-updates for the MOTD to display. +# +# Usage: +# bash scripts/check-image-updates.sh +# +# Add to cron for automatic checks (e.g. daily at 3am): +# 0 3 * * * bash /opt/stacks/matrix/scripts/check-image-updates.sh +# ============================================================= + +set -euo pipefail + +CACHE="/tmp/.docker-updates" +TMP=$(mktemp) + +echo "# generated $(date -u +"%Y-%m-%dT%H:%M:%SZ")" > "$TMP" + +while IFS='|' read -r name image; do + OUTPUT=$(docker pull "$image" 2>&1 || true) + if echo "$OUTPUT" | grep -q "Downloaded newer image"; then + echo "$name → $image" >> "$TMP" + fi +done < <(docker ps --format '{{.Names}}|{{.Image}}' | sort) + +mv "$TMP" "$CACHE" + +UPDATES=$(grep -v '^#' "$CACHE" | grep -c . || true) +if [ "$UPDATES" -gt 0 ]; then + echo "✅ $UPDATES image(s) updated — cache written to $CACHE" +else + echo "✅ All images current — cache written to $CACHE" +fi diff --git a/scripts/rotate-secrets.sh b/scripts/rotate-secrets.sh new file mode 100644 index 0000000..c3177a4 --- /dev/null +++ b/scripts/rotate-secrets.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# ============================================================= +# rotate-secrets.sh +# Regenerates TURN and LiveKit secrets in .env and restarts +# only the affected containers. +# +# Usage: +# bash scripts/rotate-secrets.sh # rotate all +# bash scripts/rotate-secrets.sh --turn # rotate TURN secret only +# bash scripts/rotate-secrets.sh --livekit # rotate LiveKit keys only +# +# ⚠️ Active calls and sessions WILL be dropped on rotation. +# Run during a maintenance window or when the server is idle. +# ============================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/../.env" + +if [ ! -f "$ENV_FILE" ]; then + echo "❌ .env not found at $ENV_FILE" + exit 1 +fi + +rotate_turn() { + echo "🔄 Rotating TURN secret..." + NEW_SECRET=$(openssl rand -hex 32) + sed -i "s|^STATIC_AUTH_SECRET=.*|STATIC_AUTH_SECRET=$NEW_SECRET|" "$ENV_FILE" + echo "✅ STATIC_AUTH_SECRET updated" + + echo "🔁 Restarting coturn and tuwunel..." + docker compose --env-file "$ENV_FILE" -f "$SCRIPT_DIR/../docker-compose.yml" \ + up -d --force-recreate coturn tuwunel + echo "✅ coturn and tuwunel restarted" +} + +rotate_livekit() { + echo "🔄 Rotating LiveKit API credentials..." + NEW_KEY=$(openssl rand -hex 16) + NEW_SECRET=$(openssl rand -hex 32) + sed -i "s|^API_KEY=.*|API_KEY=$NEW_KEY|" "$ENV_FILE" + sed -i "s|^API_SECRET=.*|API_SECRET=$NEW_SECRET|" "$ENV_FILE" + echo "✅ API_KEY and API_SECRET updated" + + echo "🔁 Restarting livekit and lk-jwt-service..." + docker compose --env-file "$ENV_FILE" -f "$SCRIPT_DIR/../docker-compose.yml" \ + up -d --force-recreate livekit lk-jwt-service + echo "✅ livekit and lk-jwt-service restarted" +} + +log_rotation() { + echo "📝 Logging rotation event..." + echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] $1" >> "$SCRIPT_DIR/../scripts/rotation.log" +} + +case "${1:-all}" in + --turn) + rotate_turn + log_rotation "TURN secret rotated" + ;; + --livekit) + rotate_livekit + log_rotation "LiveKit credentials rotated" + ;; + all|--all) + rotate_turn + rotate_livekit + log_rotation "All secrets rotated" + ;; + *) + echo "Usage: $0 [--turn | --livekit | --all]" + exit 1 + ;; +esac + +echo "" +echo "🎉 Rotation complete. Previous secrets are gone — update any external clients if needed." diff --git a/tuwunel/tuwunel.toml b/tuwunel/tuwunel.toml new file mode 100755 index 0000000..4bf0b7a --- /dev/null +++ b/tuwunel/tuwunel.toml @@ -0,0 +1,21 @@ +[global] +# This value is overridden at runtime by TUWUNEL_SERVER_NAME in docker-compose.yml, +# which pulls from DOMAIN_NAME in your .env. You do not need to edit this file. +server_name = "your.domain" + +database_path = "/var/lib/tuwunel/db" +port = 6167 +address = "0.0.0.0" + +allow_registration = false +allow_federation = true + +max_request_size = 20_000_000 +trusted_servers = ["matrix.org"] + +# Hardening +allow_inbound_profile_lookup_federation_requests = false +allow_device_name_federation = false +encryption_enabled_by_default_for_room_type = "all" + +turn_ttl = 86400