From 32eca6b9239a2569d2fa10c7de62cc5633d97c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Thu, 4 Jun 2026 10:52:05 +0200 Subject: [PATCH] feat(ess-pro/compose): deploy Element Server Suite Pro via Compose initial commit of the converted role from helm charts for qubernetis to compose ansible role --- roles/ess_pro_compose/README.md | 229 +++++++++++++ roles/ess_pro_compose/defaults/main.yml | 149 +++++++++ .../examples/group_vars-ess_servers.yml | 63 ++++ .../examples/openbao-bootstrap.sh | 20 ++ roles/ess_pro_compose/examples/site.yml | 7 + roles/ess_pro_compose/handlers/main.yml | 42 +++ roles/ess_pro_compose/meta/main.yml | 17 + roles/ess_pro_compose/tasks/config.yml | 79 +++++ roles/ess_pro_compose/tasks/deploy.yml | 24 ++ roles/ess_pro_compose/tasks/main.yml | 39 +++ roles/ess_pro_compose/tasks/postinstall.yml | 48 +++ roles/ess_pro_compose/tasks/prereq.yml | 45 +++ roles/ess_pro_compose/tasks/secrets.yml | 47 +++ .../ess_pro_compose/templates/compose.yml.j2 | 304 ++++++++++++++++++ .../templates/element-web/config.json.j2 | 33 ++ .../templates/generate-secrets.py.j2 | 102 ++++++ .../templates/haproxy/429.http.j2 | 9 + .../templates/haproxy/admin-allow-ips.lst.j2 | 4 + .../templates/haproxy/haproxy.cfg.j2 | 177 ++++++++++ .../templates/haproxy/path_map_file.j2 | 5 + .../templates/haproxy/path_map_file_get.j2 | 2 + .../templates/haproxy/well-known/client.j2 | 11 + .../haproxy/well-known/element.json.j2 | 1 + .../templates/haproxy/well-known/server.j2 | 1 + .../templates/haproxy/well-known/support.j2 | 1 + .../templates/mas/config.yaml.j2 | 114 +++++++ .../templates/postgres/configure-dbs.sh.j2 | 30 ++ .../templates/redis/redis.conf.j2 | 27 ++ .../templates/sfu/config.yaml.j2 | 32 ++ .../synapse/federation-reader.yaml.j2 | 23 ++ .../templates/synapse/homeserver.yaml.j2 | 159 +++++++++ .../templates/synapse/log_config.yaml.j2 | 16 + roles/ess_pro_compose/vars/main.yml | 46 +++ 33 files changed, 1906 insertions(+) create mode 100644 roles/ess_pro_compose/README.md create mode 100644 roles/ess_pro_compose/defaults/main.yml create mode 100644 roles/ess_pro_compose/examples/group_vars-ess_servers.yml create mode 100755 roles/ess_pro_compose/examples/openbao-bootstrap.sh create mode 100644 roles/ess_pro_compose/examples/site.yml create mode 100644 roles/ess_pro_compose/handlers/main.yml create mode 100644 roles/ess_pro_compose/meta/main.yml create mode 100644 roles/ess_pro_compose/tasks/config.yml create mode 100644 roles/ess_pro_compose/tasks/deploy.yml create mode 100644 roles/ess_pro_compose/tasks/main.yml create mode 100644 roles/ess_pro_compose/tasks/postinstall.yml create mode 100644 roles/ess_pro_compose/tasks/prereq.yml create mode 100644 roles/ess_pro_compose/tasks/secrets.yml create mode 100644 roles/ess_pro_compose/templates/compose.yml.j2 create mode 100644 roles/ess_pro_compose/templates/element-web/config.json.j2 create mode 100644 roles/ess_pro_compose/templates/generate-secrets.py.j2 create mode 100644 roles/ess_pro_compose/templates/haproxy/429.http.j2 create mode 100644 roles/ess_pro_compose/templates/haproxy/admin-allow-ips.lst.j2 create mode 100644 roles/ess_pro_compose/templates/haproxy/haproxy.cfg.j2 create mode 100644 roles/ess_pro_compose/templates/haproxy/path_map_file.j2 create mode 100644 roles/ess_pro_compose/templates/haproxy/path_map_file_get.j2 create mode 100644 roles/ess_pro_compose/templates/haproxy/well-known/client.j2 create mode 100644 roles/ess_pro_compose/templates/haproxy/well-known/element.json.j2 create mode 100644 roles/ess_pro_compose/templates/haproxy/well-known/server.j2 create mode 100644 roles/ess_pro_compose/templates/haproxy/well-known/support.j2 create mode 100644 roles/ess_pro_compose/templates/mas/config.yaml.j2 create mode 100644 roles/ess_pro_compose/templates/postgres/configure-dbs.sh.j2 create mode 100644 roles/ess_pro_compose/templates/redis/redis.conf.j2 create mode 100644 roles/ess_pro_compose/templates/sfu/config.yaml.j2 create mode 100644 roles/ess_pro_compose/templates/synapse/federation-reader.yaml.j2 create mode 100644 roles/ess_pro_compose/templates/synapse/homeserver.yaml.j2 create mode 100644 roles/ess_pro_compose/templates/synapse/log_config.yaml.j2 create mode 100644 roles/ess_pro_compose/vars/main.yml diff --git a/roles/ess_pro_compose/README.md b/roles/ess_pro_compose/README.md new file mode 100644 index 0000000..f02e2b1 --- /dev/null +++ b/roles/ess_pro_compose/README.md @@ -0,0 +1,229 @@ +# Ansible Role: ess_pro_compose + +Deploys the full **Element Server Suite Pro v26.5.1** stack as a single docker +compose project, modelled 1:1 on the official `matrix-stack` Helm chart from +Element. Fronted by the existing DMZ Traefik, secrets sourced from OpenBao +(plus locally-generated cryptographic material), same conventions as the +other `digitalboard.core` roles. + +> **Licensing note:** ESS Pro is distributed as a Helm/Kubernetes product. +> Running the Pro images under docker compose requires explicit vendor +> agreement, which is in place for this deployment. + +## Architecture + +12 services, mirroring the chart: + +``` + ┌───────────────┐ + ┌──────────────────────HTTP──▶│ element-web │ + │ └───────────────┘ + │ ┌───────────────┐ + │ ┌──────────────────HTTP──▶│ element-admin │ + │ │ └───────────────┘ + │ │ ┌───────────────┐ + │ │ ┌───────────────HTTP──▶│ mas │ ─┐ +DMZ Traefik ──┤ │ │ └───────────────┘ │ ┌──────────┐ + │ │ │ ┌───────────────┐ ├─▶│ postgres │ + │ │ │ ┌────────────HTTP──▶│ haproxy │ │ └──────────┘ + │ │ │ │ │ (Pro Image) │ │ ┌──────────┐ + │ │ │ │ └───┬─────────┬─┘ │ │ redis │ + │ │ │ │ │ │ │ └──────────┘ + │ │ │ │ ┌─────────────────┘ │ │ + │ │ │ │ ▼ ▼ │ + │ │ │ │ ┌──────────────┐ ┌────────────────┴───────┐ + │ │ │ │ │ synapse-main │◀──▶│ synapse-fed-reader-0..N│ + │ │ │ │ │ (Python) │ │ (Rust Pro worker) │ + │ │ │ │ └──────────────┘ └────────────────────────┘ + │ │ │ │ + │ │ │ └──HTTP(/.well-known)──▶ haproxy (same instance) + │ │ │ + │ │ └─────HTTP(/sfu/get)──────▶┌──────────────────┐ + │ │ │ matrix-rtc-auth │ (lk-jwt) + │ │ └──────────┬───────┘ + │ └─HTTP+TCP/30001+UDP/30002───▶┌──────────▼───────┐ + │ │ matrix-rtc-sfu │ (LiveKit) + │ └──────────────────┘ + │ + └─ HTTPS termination on Traefik, plain HTTP downstream +``` + +## Hostnames + +| Component | Hostname | +| --------------------- | ------------------------------------ | +| Matrix `serverName` | `digitalboard.ch` | +| Synapse (via HAProxy) | `matrix.digitalboard.ch` | +| MAS | `account.digitalboard.ch` | +| Element Web | `chat.digitalboard.ch` | +| Element Admin | `admin.digitalboard.ch` | +| Matrix RTC / Element Call | `mrtc.digitalboard.ch` | +| `.well-known/matrix/` | `digitalboard.ch` (apex) | + +Naming follows Element's official docs (`account.*`, `mrtc.*`). Keycloak on +`auth.digitalboard.ch` is untouched. + +## Prerequisites + +1. Collections on the control node: + + ```bash + ansible-galaxy collection install community.docker community.hashi_vault + pip install docker hvac + ``` + +2. Target host: Debian bookworm with Docker CE + compose plugin (the shared + digitalboard docker role handles this) and `python3-cryptography`. + +3. DMZ Traefik attached to the `proxy` network with a `websecure` entrypoint + and a `letsencrypt` certresolver. + +4. DNS A/AAAA records for the apex + five subdomains. + +5. DMZ firewall NAT-forwards TCP/`30001` and UDP/`30002` to the host (Element + Call media ports — fixed by the chart, not the wide 50k–60k range). + +6. ESS Pro registry credentials (and Authentik OIDC client secret) bootstrapped + in OpenBao at `kv/digitalboard/ess-compose` via + `examples/openbao-bootstrap.sh`. + +## How secrets work + +Two layers: + +- **From OpenBao:** Element registry username/token and Authentik OIDC client + secret. Pulled at playbook time via `community.hashi_vault.vault_kv2_get` + lookups, same pattern as the other digitalboard.core roles. + +- **Generated locally:** The 14 cryptographic secrets the chart's + `init-secrets` job normally produces (Synapse signing key, MAS RSA/ECDSA + keys, Synapse↔MAS shared secret, replication secret, Postgres passwords, + LiveKit secret, admin user password). A Python script bundled with the role + generates them on first run into `/opt/ess/secrets/` and never overwrites + existing files — runs of the playbook are idempotent. All containers mount + this directory read-only as `/secrets/ess-generated/` (matches the chart's + mount path). + +The MAS RSA key is generated in DER PKCS8 format, ECDSA in PEM PKCS8, and the +Synapse signing key in Synapse's native `ed25519 ` format. +All formats verified against what the chart's `matrix-tools generate-secrets` +produces. + +## Usage + +```yaml +# site.yml +- hosts: ess_servers + become: true + roles: + - digitalboard.core.ess_pro_compose +``` + +```yaml +# inventory/group_vars/ess_servers.yml -- see examples/ +ess_server_name: "digitalboard.ch" +ess_synapse_fed_reader_replicas: 5 +ess_oidc_enabled: true +ess_oidc_issuer: "https://authentik.digitalboard.ch/application/o/ess/" +ess_rtc_external_ip: "203.0.113.42" + +ess_registry_username: "{{ lookup('community.hashi_vault.vault_kv2_get', ...).data.data.registry_username }}" +ess_registry_token: "{{ lookup('community.hashi_vault.vault_kv2_get', ...).data.data.registry_token }}" +ess_oidc_client_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', ...).data.data.oidc_client_secret }}" +``` + +Run: `ansible-playbook -i inventories/digitalboard site.yml` + +The role creates `@localadmin:digitalboard.ch` via `mas-cli` and prints the +location of the generated password (`/opt/ess/secrets/ADMIN_USER_PASSWORD` on +the host). + +## Post-deploy verification + +```bash +# All containers healthy +docker compose -f /opt/ess/compose.yml ps + +# Synapse + MAS<-->Synapse wiring +curl -sS https://matrix.digitalboard.ch/_matrix/client/versions | jq .versions +curl -sS https://digitalboard.ch/.well-known/matrix/server | jq +curl -sS https://digitalboard.ch/.well-known/matrix/client | jq + +# MAS sanity +docker compose -f /opt/ess/compose.yml exec mas \ + mas-cli --config /conf/mas-config.yaml doctor + +# HAProxy stats (internally) +docker compose -f /opt/ess/compose.yml exec haproxy \ + wget -qO- http://localhost:8405/metrics | head +``` + +## Operations + +- **Config change:** re-run the playbook. Changed templates trigger + per-component `docker compose restart` via handlers. +- **Image upgrade:** bump `ess_images.` in defaults or group_vars, + re-run. +- **Scale federation-reader:** change `ess_synapse_fed_reader_replicas`, re-run + (HAProxy backend list is rendered from the same variable). +- **Logs:** `docker compose -f /opt/ess/compose.yml logs -f synapse-main` +- **Tear down:** `docker compose -f /opt/ess/compose.yml down -v` + +## What's faithful to the chart, what's adapted + +**Faithful to chart v26.5.1:** +- All image paths from `registry.element.io` (correct repos: `synapse-onprem`, + `synapse-pro-worker`, `matrix-authentication-service`, `element-web-pro`, + `element-admin`, `haproxy`, `livekit-server-distroless`, `lk-jwt-service`, + `postgres`, `redis-distroless`). +- HAProxy config 1:1 from the chart (path-based routing to fed-reader for + `/event`, `/state`, `/state_ids`, admin IP allow-list, well-known + serving on port 8010, 429.http for queue overflow). +- Synapse `homeserver.yaml` merged from the chart's four fragments + (underrides + overrides + main listeners + log config) with both Pro + modules loaded (`synapse_ess_pro.EssPro`, + `synapse_mass_local_room_upgrades.MassLocalRoomUpgradesModule`). +- MAS config with all four listeners (web 8080, internal 8081, root 8082, + synapse 8083) and `kind: synapse_modern` for delegated auth. +- federation-reader (Rust worker) config in its native schema, not + Synapse-Python-worker syntax. +- LiveKit on TCP 30001 + UDP 30002 muxed, with `node_ip` set for ICE. +- Element Web config with Pro features (`use_exclusively`, + `element-pro` mobile variant). +- Init-secrets bundle generated with matching key types and formats + (rand32 url-safe / hex32 / rsa:4096:der / ecdsaprime256v1 PEM / + Synapse ed25519 signing key). + +**Adapted for compose:** +- K8s DNS-SRV service discovery (`_synapse-http._tcp.X.svc.cluster.local`) + replaced with direct compose service names + the embedded DNS resolver + (`127.0.0.11:53`). HAProxy backend entries use plain hostnames. +- StatefulSet PVCs replaced with named docker volumes. +- The chart's `matrix-tools render-config` init-container is replaced by + Ansible Jinja2 template rendering on the control node — same merge order, + no Python interpreter in init-containers. +- The chart's `init-secrets` K8s job is replaced by the local + generate-secrets script. +- Postgres `postgres-ess-updater` sidecar (which re-runs the init script + in case of password changes) is omitted; first-boot init via + `/docker-entrypoint-initdb.d/` is sufficient for compose, since the + generated passwords don't rotate on re-run (idempotent secrets). +- No Synapse Pro autoscaler (K8s HPA only); replica count is static via + `ess_synapse_fed_reader_replicas`. + +## Things not yet wired (optional Pro components) + +The chart can also deploy these — not included in this role's first pass, +add as needed: + +- Hookshot (Matrix bot framework for GitHub/GitLab/JIRA bridges) +- Secure Border Gateway (Federation app-firewall — only relevant if you + federate with strict-control orgs / German TI-Messenger) +- Advanced Identity Management (LDAP/SCIM provisioning) +- AuditBot, AdminBot, supervision +- Sygnal (mobile push gateway) +- Telemetry service (chart deploys this by default; here it's optional) +- Content scanner + +Each maps to its own template directory in `charts/matrix-stack/templates/` +and can be added later as additional compose services. diff --git a/roles/ess_pro_compose/defaults/main.yml b/roles/ess_pro_compose/defaults/main.yml new file mode 100644 index 0000000..ebc0b27 --- /dev/null +++ b/roles/ess_pro_compose/defaults/main.yml @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: MIT-0 +--- +# ============================================================================= +# ess_pro_compose role — defaults +# ============================================================================= +# Deploys the full ESS Pro stack (matrix-stack chart v26.5.1) as a docker +# compose project, including the Pro federation-reader worker. Same conventions +# as the other digitalboard.core roles. Secrets are sourced from OpenBao. + +# ----------------------------------------------------------------------------- +# Chart version we're modelling +# ----------------------------------------------------------------------------- +ess_chart_version: "26.5.1" + +# ----------------------------------------------------------------------------- +# Project layout on the target host +# ----------------------------------------------------------------------------- +ess_compose_dir: "/opt/ess" +ess_compose_project_name: "ess" + +# Where rendered configs and runtime data live (mounted into containers) +ess_compose_conf_dir: "{{ ess_compose_dir }}/conf" # rendered configs +ess_compose_secrets_dir: "{{ ess_compose_dir }}/secrets" # generated secrets (0600) +ess_compose_data_dir: "{{ ess_compose_dir }}/data" # volumes + +# ----------------------------------------------------------------------------- +# Docker networks +# ----------------------------------------------------------------------------- +# Public-facing Traefik network (external, managed by the shared traefik role). +ess_compose_traefik_network: "proxy" +ess_compose_traefik_entrypoint: "websecure" +ess_compose_traefik_certresolver: "letsencrypt" + +# Internal network for service-to-service traffic only. +ess_compose_internal_network: "ess_internal" + +# ----------------------------------------------------------------------------- +# Matrix identity +# ----------------------------------------------------------------------------- +# Matrix serverName is the domain part of @user:serverName. Immutable. +ess_server_name: "digitalboard.ch" + +# Hostnames. Convention follows the official Element docs (account.*, mrtc.*). +# Override per environment in group_vars if you want different prefixes. +ess_hostnames: + synapse: "matrix.{{ ess_server_name }}" # client + federation, fronts HAProxy + mas: "account.{{ ess_server_name }}" # Matrix Authentication Service + element_web: "chat.{{ ess_server_name }}" + element_admin: "admin.{{ ess_server_name }}" + matrix_rtc: "mrtc.{{ ess_server_name }}" # Element Call SFU + auth + +# ----------------------------------------------------------------------------- +# Image references (Pro images from registry.element.io, chart 26.5.1) +# ----------------------------------------------------------------------------- +# Pin to specific tags for production. The chart bundles digests; we use +# version-aligned tags so they're readable. Override individually as needed. +ess_images: + synapse: "registry.element.io/synapse-onprem:sha-63110a4" + synapse_pro_worker: "registry.element.io/synapse-pro-worker:0.4.0" + mas: "registry.element.io/matrix-authentication-service:1.17.0" + element_web: "registry.element.io/element-web-pro:1.12.18" + element_admin: "registry.element.io/element-admin:1.5.0" + haproxy: "registry.element.io/haproxy:3.2-alpine" + livekit: "registry.element.io/livekit-server-distroless:1.9.1" + lk_jwt: "registry.element.io/lk-jwt-service:0.3.0" + postgres: "registry.element.io/postgres:16-alpine" + postgres_exporter: "registry.element.io/postgres-exporter:0.18.1" + redis: "registry.element.io/redis-distroless:7.4" + matrix_tools: "registry.element.io/matrix-tools:0.17.8" + +# ----------------------------------------------------------------------------- +# Element registry credentials (from customer.element.io) +# ----------------------------------------------------------------------------- +ess_registry_url: "registry.element.io" +ess_registry_username: "" # OpenBao lookup in group_vars +ess_registry_token: "" # OpenBao lookup in group_vars + +# ----------------------------------------------------------------------------- +# Federation reader worker +# ----------------------------------------------------------------------------- +# The Rust-based Pro worker that handles /state, /state_ids, /event federation +# reads. The chart deploys this with 20 replicas; for compose we run it as +# scaled instances. +ess_synapse_fed_reader_replicas: 1 + +# ----------------------------------------------------------------------------- +# Delegated authentication via the digitalboard IdP +# ----------------------------------------------------------------------------- +# Authentik in the demo environment, Keycloak in production. Discover the +# exact issuer with: +# curl -s /.well-known/openid-configuration | jq .issuer +ess_oidc_enabled: false +ess_oidc_issuer: "" +ess_oidc_client_id: "ess-mas" +ess_oidc_client_secret: "" # OpenBao +ess_oidc_provider_name: "Digitalboard" +ess_oidc_provider_ulid: "01JBADAUTHENTIKDIGITALBOARD01" +ess_oidc_scopes: "openid profile email" + +# ----------------------------------------------------------------------------- +# Matrix RTC / Element Call (LiveKit SFU) +# ----------------------------------------------------------------------------- +# Element's Pro chart fixes RTC to TCP 30001 + UDP 30002 (muxed). Forward +# those on the DMZ firewall to this host. +ess_rtc_tcp_port: 30001 +ess_rtc_udp_port: 30002 + +# Public IP for ICE candidates (the DMZ NAT address). Required. +ess_rtc_external_ip: "" +# LiveKit non-secret key id (the secret comes from the generated bundle). +ess_livekit_key: "matrix-rtc" + +# ----------------------------------------------------------------------------- +# Registration / federation policy +# ----------------------------------------------------------------------------- +ess_enable_registration: false +ess_enable_federation: true # internet federation; turn off for isolated POCs +ess_admin_contact: "mailto:admin@{{ ess_server_name }}" + +# ----------------------------------------------------------------------------- +# Initial admin user +# ----------------------------------------------------------------------------- +# A localadmin user is created on first deploy via mas-cli. The generated +# password lands in {{ ess_compose_secrets_dir }}/ADMIN_USER_PASSWORD. +ess_admin_localpart: "localadmin" +ess_create_admin_user: true + +# ----------------------------------------------------------------------------- +# Element Admin / Synapse Admin allow-list +# ----------------------------------------------------------------------------- +# Source IPs (CIDR) allowed to hit /_synapse/admin/. Default: everyone. Lock +# this down for production (e.g. just the office network + bastion). +ess_admin_allow_ips: + - "0.0.0.0/0" + - "::/0" + +# ----------------------------------------------------------------------------- +# Resources / sizing (Postgres args) +# ----------------------------------------------------------------------------- +# Chart defaults assume a fairly beefy node. Adjust for your VM. +ess_postgres_max_connections: 256 +ess_postgres_shared_buffers: "1024MB" +ess_postgres_effective_cache_size: "3840MB" + +# ----------------------------------------------------------------------------- +# Synapse media store +# ----------------------------------------------------------------------------- +ess_synapse_max_upload_size: "100M" +ess_synapse_url_previews_enabled: true diff --git a/roles/ess_pro_compose/examples/group_vars-ess_servers.yml b/roles/ess_pro_compose/examples/group_vars-ess_servers.yml new file mode 100644 index 0000000..f78dac0 --- /dev/null +++ b/roles/ess_pro_compose/examples/group_vars-ess_servers.yml @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: MIT-0 +--- +# inventory/group_vars/ess_servers.yml +# Production config: full Pro stack, secrets from OpenBao. + +# ---- Matrix identity ---------------------------------------------------- +ess_server_name: "digitalboard.ch" +# Default hostnames (matrix./account./chat./admin./mrtc.) inherit from +# ess_server_name. Override `ess_hostnames` here if you need different prefixes. + +# ---- Pro worker scaling ------------------------------------------------- +# Federation-reader workers (Rust). Chart deploys 20 in K8s with HPA. +# For a 500-700 user vocational school deployment, 3-5 is plenty. +ess_synapse_fed_reader_replicas: 5 + +# ---- DMZ Traefik integration -------------------------------------------- +ess_compose_traefik_network: "proxy" +ess_compose_traefik_entrypoint: "websecure" +ess_compose_traefik_certresolver: "letsencrypt" + +# ---- Registration / federation policy ----------------------------------- +ess_enable_registration: false +ess_enable_federation: true + +# ---- Delegated auth via Authentik (demo) / Keycloak (prod) -------------- +ess_oidc_enabled: true +# Verify the actual issuer with: +# curl -s /.well-known/openid-configuration | jq .issuer +ess_oidc_issuer: "https://authentik.digitalboard.ch/application/o/ess/" +ess_oidc_client_id: "ess-mas" +ess_oidc_provider_name: "Digitalboard" + +# ---- Matrix RTC / Element Call ------------------------------------------ +ess_rtc_external_ip: "203.0.113.42" # DMZ public IP — set for your env + +# ---- Admin allow-list (lock down for prod!) ----------------------------- +ess_admin_allow_ips: + - "10.0.0.0/8" # internal RFC1918 + - "172.16.0.0/12" + - "192.168.0.0/16" + - "203.0.113.5/32" # bastion IP + +# ============================================================================= +# Secrets — from OpenBao (same pattern as bookstack/opnform/homarr) +# ============================================================================= +# +# Stored at kv/digitalboard/ess-compose with two keys (registry creds only — +# the cryptographic material is generated locally by the role's +# generate-secrets script and lives in {{ ess_compose_secrets_dir }} on the +# host). The OIDC client secret also lives in OpenBao because it's shared +# with the IdP side. + +ess_registry_username: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/ess-compose', + mount_point='kv').data.data.registry_username }}" + +ess_registry_token: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/ess-compose', + mount_point='kv').data.data.registry_token }}" + +ess_oidc_client_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/ess-compose', + mount_point='kv').data.data.oidc_client_secret }}" diff --git a/roles/ess_pro_compose/examples/openbao-bootstrap.sh b/roles/ess_pro_compose/examples/openbao-bootstrap.sh new file mode 100755 index 0000000..63d51c1 --- /dev/null +++ b/roles/ess_pro_compose/examples/openbao-bootstrap.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Bootstrap the OpenBao entry for ess_pro_compose. +# Only stores the registry credentials and the OIDC client secret — +# the rest of the cryptographic material is generated by the role locally +# on first deploy (and persists in {{ ess_compose_secrets_dir }} on the host). + +set -euo pipefail +MOUNT="${MOUNT:-kv}" +PATH_="${PATH_:-digitalboard/ess-compose}" + +read -p "Element registry username (from customer.element.io): " REG_USER +read -sp "Element registry token: " REG_TOKEN; echo +read -sp "Authentik OIDC client_secret for ess-mas: " OIDC_SECRET; echo + +bao kv put "${MOUNT}/${PATH_}" \ + registry_username="${REG_USER}" \ + registry_token="${REG_TOKEN}" \ + oidc_client_secret="${OIDC_SECRET}" + +echo "Done. Verify: bao kv get ${MOUNT}/${PATH_}" diff --git a/roles/ess_pro_compose/examples/site.yml b/roles/ess_pro_compose/examples/site.yml new file mode 100644 index 0000000..948ab06 --- /dev/null +++ b/roles/ess_pro_compose/examples/site.yml @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: MIT-0 +--- +- name: Deploy ESS Pro v26.5.1 (full stack with federation-reader worker) + hosts: ess_servers + become: true + roles: + - digitalboard.core.ess_pro_compose diff --git a/roles/ess_pro_compose/handlers/main.yml b/roles/ess_pro_compose/handlers/main.yml new file mode 100644 index 0000000..0cdac71 --- /dev/null +++ b/roles/ess_pro_compose/handlers/main.yml @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: MIT-0 +--- +- name: Restart haproxy + community.docker.docker_compose_v2: + project_src: "{{ ess_compose_dir }}" + services: [haproxy] + state: restarted + +- name: Restart synapse-main + community.docker.docker_compose_v2: + project_src: "{{ ess_compose_dir }}" + services: [synapse-main] + state: restarted + +- name: Restart synapse-fed-reader + community.docker.docker_compose_v2: + project_src: "{{ ess_compose_dir }}" + state: restarted + +- name: Restart mas + community.docker.docker_compose_v2: + project_src: "{{ ess_compose_dir }}" + services: [mas] + state: restarted + +- name: Restart matrix-rtc-sfu + community.docker.docker_compose_v2: + project_src: "{{ ess_compose_dir }}" + services: [matrix-rtc-sfu, matrix-rtc-authorisation] + state: restarted + +- name: Restart element-web + community.docker.docker_compose_v2: + project_src: "{{ ess_compose_dir }}" + services: [element-web] + state: restarted + +- name: Restart redis + community.docker.docker_compose_v2: + project_src: "{{ ess_compose_dir }}" + services: [redis] + state: restarted diff --git a/roles/ess_pro_compose/meta/main.yml b/roles/ess_pro_compose/meta/main.yml new file mode 100644 index 0000000..a931a25 --- /dev/null +++ b/roles/ess_pro_compose/meta/main.yml @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: MIT-0 +--- +galaxy_info: + role_name: ess_pro_compose + author: digitalboard + description: Full ESS Pro stack (matrix-stack v26.5.1) via docker compose, with federation-reader worker + license: MIT + min_ansible_version: "2.14" + platforms: + - name: Debian + versions: + - bookworm + +dependencies: [] + +collections: + - community.docker diff --git a/roles/ess_pro_compose/tasks/config.yml b/roles/ess_pro_compose/tasks/config.yml new file mode 100644 index 0000000..5929f4b --- /dev/null +++ b/roles/ess_pro_compose/tasks/config.yml @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: MIT-0 +--- +# Render every component's configuration. Each template uses _ess_secrets +# facts (loaded in secrets.yml) for password substitution. + +- name: Render HAProxy config + ansible.builtin.template: + src: "{{ item.src }}" + dest: "{{ ess_compose_conf_dir }}/haproxy/{{ item.dest }}" + mode: "0640" + loop: + - { src: haproxy/haproxy.cfg.j2, dest: haproxy.cfg } + - { src: haproxy/429.http.j2, dest: 429.http } + - { src: haproxy/path_map_file.j2, dest: path_map_file } + - { src: haproxy/path_map_file_get.j2, dest: path_map_file_get } + - { src: haproxy/admin-allow-ips.lst.j2, dest: admin-allow-ips.lst } + notify: Restart haproxy + +- name: Render well-known files + ansible.builtin.template: + src: "haproxy/well-known/{{ item }}.j2" + dest: "{{ ess_compose_conf_dir }}/haproxy/well-known/{{ item }}" + mode: "0644" + loop: + - server + - client + - support + - element.json + notify: Restart haproxy + +- name: Render Synapse configs + ansible.builtin.template: + src: "{{ item.src }}" + dest: "{{ ess_compose_conf_dir }}/synapse/{{ item.dest }}" + mode: "0640" + loop: + - { src: synapse/homeserver.yaml.j2, dest: homeserver.yaml } + - { src: synapse/log_config.yaml.j2, dest: log_config.yaml } + - { src: synapse/federation-reader.yaml.j2, dest: federation-reader.yaml } + no_log: true + notify: + - Restart synapse-main + - Restart synapse-fed-reader + +- name: Render MAS config + ansible.builtin.template: + src: mas/config.yaml.j2 + dest: "{{ ess_compose_conf_dir }}/mas/config.yaml" + mode: "0640" + no_log: true + notify: Restart mas + +- name: Render SFU config + ansible.builtin.template: + src: sfu/config.yaml.j2 + dest: "{{ ess_compose_conf_dir }}/sfu/config.yaml" + mode: "0640" + no_log: true + notify: Restart matrix-rtc-sfu + +- name: Render Element Web config + ansible.builtin.template: + src: element-web/config.json.j2 + dest: "{{ ess_compose_conf_dir }}/element-web/config.json" + mode: "0644" + notify: Restart element-web + +- name: Render Postgres init script + ansible.builtin.template: + src: postgres/configure-dbs.sh.j2 + dest: "{{ ess_compose_conf_dir }}/postgres/configure-dbs.sh" + mode: "0755" + +- name: Render Redis config + ansible.builtin.template: + src: redis/redis.conf.j2 + dest: "{{ ess_compose_conf_dir }}/redis/redis.conf" + mode: "0644" + notify: Restart redis diff --git a/roles/ess_pro_compose/tasks/deploy.yml b/roles/ess_pro_compose/tasks/deploy.yml new file mode 100644 index 0000000..a96c9de --- /dev/null +++ b/roles/ess_pro_compose/tasks/deploy.yml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: MIT-0 +--- +- name: Render compose project file + ansible.builtin.template: + src: compose.yml.j2 + dest: "{{ _ess_compose_file }}" + mode: "0640" + +- name: Pull all images + community.docker.docker_compose_v2_pull: + project_src: "{{ ess_compose_dir }}" + register: ess_pull_result + +- name: Bring the stack up + community.docker.docker_compose_v2: + project_src: "{{ ess_compose_dir }}" + state: present + wait: true + wait_timeout: 300 + register: ess_up_result + +- name: Show running services + ansible.builtin.debug: + msg: "{{ ess_up_result.services | default([]) | map(attribute='Service') | list }}" diff --git a/roles/ess_pro_compose/tasks/main.yml b/roles/ess_pro_compose/tasks/main.yml new file mode 100644 index 0000000..b5cd08f --- /dev/null +++ b/roles/ess_pro_compose/tasks/main.yml @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: MIT-0 +--- +- name: Validate required variables + ansible.builtin.assert: + that: + - ess_server_name | length > 0 + - ess_registry_username | length > 0 + - ess_registry_token | length > 0 + - ess_rtc_external_ip | length > 0 + fail_msg: >- + Required variables are missing. Provide ess_server_name, + ess_registry_username, ess_registry_token (OpenBao) and + ess_rtc_external_ip in group_vars/ess_servers.yml. + quiet: true + +- name: Validate OIDC variables when OIDC is enabled + ansible.builtin.assert: + that: + - ess_oidc_issuer | length > 0 + - ess_oidc_client_secret | length > 0 + fail_msg: OIDC enabled but issuer / client_secret missing. + quiet: true + when: ess_oidc_enabled | bool + +- name: Prerequisites (docker, networks, dirs, registry login) + ansible.builtin.import_tasks: prereq.yml + +- name: Generate / verify the ess-generated secret bundle + ansible.builtin.import_tasks: secrets.yml + +- name: Render all component configuration files + ansible.builtin.import_tasks: config.yml + +- name: Render compose project file and start the stack + ansible.builtin.import_tasks: deploy.yml + +- name: Post-install (create admin user) + ansible.builtin.import_tasks: postinstall.yml + when: ess_create_admin_user | bool diff --git a/roles/ess_pro_compose/tasks/postinstall.yml b/roles/ess_pro_compose/tasks/postinstall.yml new file mode 100644 index 0000000..caa7850 --- /dev/null +++ b/roles/ess_pro_compose/tasks/postinstall.yml @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: MIT-0 +--- +# Create @localadmin via mas-cli, using the ADMIN_USER_PASSWORD generated +# by secrets.yml. Idempotent: mas-cli rejects duplicates, we ignore that. + +- name: Read generated admin password + ansible.builtin.slurp: + src: "{{ ess_compose_secrets_dir }}/ADMIN_USER_PASSWORD" + register: _ess_admin_pw_slurp + no_log: true + +- name: Check whether the admin user already exists + ansible.builtin.command: + cmd: > + docker compose -f {{ _ess_compose_file }} + exec -T mas + mas-cli --config /conf/mas-config.yaml + manage list-users --filter username={{ ess_admin_localpart }} + register: _ess_admin_check + changed_when: false + failed_when: false + +- name: Register admin user (mas-cli) + ansible.builtin.command: + cmd: > + docker compose -f {{ _ess_compose_file }} + exec -T mas + mas-cli --config /conf/mas-config.yaml + manage register-user --yes + --password {{ (_ess_admin_pw_slurp.content | b64decode).strip() | quote }} + --admin + {{ ess_admin_localpart }} + register: _ess_admin_create + changed_when: "'created' in (_ess_admin_create.stdout + _ess_admin_create.stderr) | lower" + failed_when: + - _ess_admin_create.rc != 0 + - "'already exists' not in (_ess_admin_create.stdout + _ess_admin_create.stderr) | lower" + no_log: true + when: ess_admin_localpart not in _ess_admin_check.stdout + +- name: Login hint + ansible.builtin.debug: + msg: | + Stack is up. + Admin user: @{{ ess_admin_localpart }}:{{ ess_server_name }} + Password is in {{ ess_compose_secrets_dir }}/ADMIN_USER_PASSWORD on this host. + Element Web: https://{{ ess_hostnames.element_web }} + Element Admin: https://{{ ess_hostnames.element_admin }} diff --git a/roles/ess_pro_compose/tasks/prereq.yml b/roles/ess_pro_compose/tasks/prereq.yml new file mode 100644 index 0000000..93158d0 --- /dev/null +++ b/roles/ess_pro_compose/tasks/prereq.yml @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: MIT-0 +--- +- name: Ensure prerequisite packages on the control target + ansible.builtin.apt: + name: + - ca-certificates + - python3-docker + - python3-cryptography + state: present + update_cache: true + +- name: Verify docker compose plugin is available + ansible.builtin.command: docker compose version + register: ess_compose_check + changed_when: false + failed_when: ess_compose_check.rc != 0 + +- name: Create project directory tree + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0750" + owner: root + group: root + loop: "{{ _ess_dirs }}" + +- name: Tighten secrets directory permissions + ansible.builtin.file: + path: "{{ ess_compose_secrets_dir }}" + state: directory + mode: "0700" + owner: root + group: root + +- name: Ensure the external Traefik proxy network exists + community.docker.docker_network: + name: "{{ ess_compose_traefik_network }}" + state: present + +- name: Authenticate against the Element container registry + community.docker.docker_login: + registry_url: "{{ ess_registry_url }}" + username: "{{ ess_registry_username }}" + password: "{{ ess_registry_token }}" + no_log: true diff --git a/roles/ess_pro_compose/tasks/secrets.yml b/roles/ess_pro_compose/tasks/secrets.yml new file mode 100644 index 0000000..d81ede5 --- /dev/null +++ b/roles/ess_pro_compose/tasks/secrets.yml @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: MIT-0 +--- +# Generate the ess-generated secret bundle. Mirrors the chart's `init-secrets` +# job, but runs locally on the host. Idempotent — only writes missing files. + +- name: Render generate-secrets script + ansible.builtin.template: + src: generate-secrets.py.j2 + dest: "{{ ess_compose_dir }}/.generate-secrets.py" + mode: "0700" + +- name: Run generate-secrets (creates only what's missing) + ansible.builtin.command: + cmd: "/usr/bin/python3 {{ ess_compose_dir }}/.generate-secrets.py" + register: ess_secrets_run + changed_when: "'CREATED:' in ess_secrets_run.stdout" + +- name: Verify every required secret exists + ansible.builtin.stat: + path: "{{ ess_compose_secrets_dir }}/{{ item }}" + register: ess_secret_stat + loop: "{{ _ess_secret_names }}" + failed_when: not ess_secret_stat.stat.exists + +- name: Read postgres passwords for config templates (not persisted) + ansible.builtin.slurp: + src: "{{ ess_compose_secrets_dir }}/{{ item }}" + register: ess_password_slurp + loop: + - POSTGRES_ADMIN_PASSWORD + - POSTGRES_SYNAPSE_PASSWORD + - POSTGRES_MATRIX_AUTHENTICATION_SERVICE_PASSWORD + - SYNAPSE_MACAROON + - SYNAPSE_REGISTRATION_SHARED_SECRET + - SYNAPSE_WORKERS_REPLICATION_SECRET + - MAS_SYNAPSE_SHARED_SECRET + - MAS_MATRIX_TOOLS_OIDC_CLIENT_SECRET + - ELEMENT_CALL_LIVEKIT_SECRET + no_log: true + +- name: Expose passwords as facts for templates + ansible.builtin.set_fact: + _ess_secrets: "{{ _ess_secrets | default({}) | combine({item.item: (item.content | b64decode).strip()}) }}" + loop: "{{ ess_password_slurp.results }}" + loop_control: + label: "{{ item.item }}" + no_log: true diff --git a/roles/ess_pro_compose/templates/compose.yml.j2 b/roles/ess_pro_compose/templates/compose.yml.j2 new file mode 100644 index 0000000..514341c --- /dev/null +++ b/roles/ess_pro_compose/templates/compose.yml.j2 @@ -0,0 +1,304 @@ +# {{ ansible_managed }} +# ESS Pro v{{ ess_chart_version }} on docker compose — rendered by ess_pro_compose. +# Topology mirrors the Helm chart: HAProxy fronts all Synapse traffic, +# synapse-main is the Python homeserver, synapse-fed-reader is the Rust Pro +# worker handling federation reads, MAS handles all auth, LiveKit + lk-jwt +# serve Element Call. + +name: {{ ess_compose_project_name }} + +networks: + {{ ess_compose_traefik_network }}: + external: true + {{ ess_compose_internal_network }}: + driver: bridge + +volumes: + postgres_data: + synapse_media: + +services: + + # =========================================================================== + # Data plane + # =========================================================================== + + postgres: + image: {{ ess_images.postgres }} + container_name: postgres + restart: unless-stopped + networks: [ {{ ess_compose_internal_network }} ] + environment: + LC_COLLATE: "C" + LC_CTYPE: "C" + PGDATA: /var/lib/postgresql/data/pgdata + POSTGRES_INITDB_ARGS: "-E UTF8" + POSTGRES_PASSWORD_FILE: /secrets/ess-generated/POSTGRES_ADMIN_PASSWORD + command: + - postgres + - "-c" + - "max_connections={{ ess_postgres_max_connections }}" + - "-c" + - "shared_buffers={{ ess_postgres_shared_buffers }}" + - "-c" + - "effective_cache_size={{ ess_postgres_effective_cache_size }}" + volumes: + - postgres_data:/var/lib/postgresql/data + - {{ ess_compose_secrets_dir }}:/secrets/ess-generated:ro + - {{ ess_compose_conf_dir }}/postgres/configure-dbs.sh:/docker-entrypoint-initdb.d/init-ess-dbs.sh:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: {{ ess_images.redis }} + container_name: redis + restart: unless-stopped + networks: [ {{ ess_compose_internal_network }} ] + command: ["/usr/local/etc/redis/redis.conf"] + volumes: + - {{ ess_compose_conf_dir }}/redis/redis.conf:/usr/local/etc/redis/redis.conf:ro + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + + # =========================================================================== + # Synapse (Python main + Rust federation-reader worker) + # =========================================================================== + + synapse-main: + image: {{ ess_images.synapse }} + container_name: synapse-main + restart: unless-stopped + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_healthy } + networks: [ {{ ess_compose_internal_network }} ] + command: ["python3", "-m", "synapse.app.homeserver", "-c", "/conf/homeserver.yaml"] + volumes: + - {{ ess_compose_conf_dir }}/synapse/homeserver.yaml:/conf/homeserver.yaml:ro + - {{ ess_compose_conf_dir }}/synapse/log_config.yaml:/conf/log_config.yaml:ro + - {{ ess_compose_secrets_dir }}:/secrets/ess-generated:ro + - synapse_media:/media + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 60s + +{% for i in range(ess_synapse_fed_reader_replicas | int) %} + synapse-fed-reader-{{ i }}: + image: {{ ess_images.synapse_pro_worker }} + container_name: synapse-fed-reader-{{ i }} + restart: unless-stopped + depends_on: + synapse-main: { condition: service_healthy } + networks: [ {{ ess_compose_internal_network }} ] + environment: + APP_CONFIG_FILEPATH: /conf/federation-reader.yaml + volumes: + - {{ ess_compose_conf_dir }}/synapse/federation-reader.yaml:/conf/federation-reader.yaml:ro + - {{ ess_compose_secrets_dir }}:/secrets/ess-generated:ro + +{% endfor %} + # =========================================================================== + # Matrix Authentication Service (4 listeners) + # =========================================================================== + + mas: + image: {{ ess_images.mas }} + container_name: mas + restart: unless-stopped + depends_on: + postgres: { condition: service_healthy } + networks: + - {{ ess_compose_internal_network }} + - {{ ess_compose_traefik_network }} + environment: + MAS_CONFIG: /conf/mas-config.yaml + command: ["server", "--no-migrate"] + volumes: + - {{ ess_compose_conf_dir }}/mas/config.yaml:/conf/mas-config.yaml:ro + - {{ ess_compose_secrets_dir }}:/secrets/ess-generated:ro + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8081/health"] + interval: 10s + timeout: 5s + retries: 20 + start_period: 30s + labels: + - "traefik.enable=true" + - "traefik.docker.network={{ ess_compose_traefik_network }}" + - "traefik.http.routers.ess-mas.rule=Host(`{{ ess_hostnames.mas }}`)" + - "traefik.http.routers.ess-mas.entrypoints={{ ess_compose_traefik_entrypoint }}" + - "traefik.http.routers.ess-mas.tls=true" +{% if ess_compose_traefik_certresolver | length > 0 %} + - "traefik.http.routers.ess-mas.tls.certresolver={{ ess_compose_traefik_certresolver }}" +{% endif %} + - "traefik.http.services.ess-mas.loadbalancer.server.port=8080" + + # MAS root listener (port 8082) is mounted as a separate Traefik router so + # /.well-known/openid-configuration on the apex of the mas host is reachable. + # We attach a second router on the same service via a path rule. + + # =========================================================================== + # HAProxy — fronts all Synapse + well-known traffic + # =========================================================================== + + haproxy: + image: {{ ess_images.haproxy }} + container_name: haproxy + restart: unless-stopped + depends_on: + synapse-main: { condition: service_healthy } + networks: + - {{ ess_compose_internal_network }} + - {{ ess_compose_traefik_network }} + command: ["-f", "/usr/local/etc/haproxy/haproxy.cfg", "-dW"] + volumes: + - {{ ess_compose_conf_dir }}/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + - {{ ess_compose_conf_dir }}/haproxy/path_map_file:/synapse/path_map_file:ro + - {{ ess_compose_conf_dir }}/haproxy/path_map_file_get:/synapse/path_map_file_get:ro + - {{ ess_compose_conf_dir }}/haproxy/429.http:/synapse/429.http:ro + - {{ ess_compose_conf_dir }}/haproxy/admin-allow-ips.lst:/synapse/admin-allow-ips.lst:ro + - {{ ess_compose_conf_dir }}/haproxy/well-known/server:/well-known/server:ro + - {{ ess_compose_conf_dir }}/haproxy/well-known/client:/well-known/client:ro + - {{ ess_compose_conf_dir }}/haproxy/well-known/support:/well-known/support:ro + - {{ ess_compose_conf_dir }}/haproxy/well-known/element.json:/well-known/element.json:ro + healthcheck: + test: ["CMD", "wget", "-q", "-O-", "http://localhost:8406/synapse_ready"] + interval: 15s + timeout: 5s + retries: 20 + start_period: 90s + labels: + # matrix. -> HAProxy frontend synapse-http-in (port 8008) + - "traefik.enable=true" + - "traefik.docker.network={{ ess_compose_traefik_network }}" + - "traefik.http.routers.ess-synapse.rule=Host(`{{ ess_hostnames.synapse }}`)" + - "traefik.http.routers.ess-synapse.entrypoints={{ ess_compose_traefik_entrypoint }}" + - "traefik.http.routers.ess-synapse.tls=true" +{% if ess_compose_traefik_certresolver | length > 0 %} + - "traefik.http.routers.ess-synapse.tls.certresolver={{ ess_compose_traefik_certresolver }}" +{% endif %} + - "traefik.http.routers.ess-synapse.service=ess-synapse" + - "traefik.http.services.ess-synapse.loadbalancer.server.port=8008" + # /.well-known/matrix -> HAProxy well-known-in (port 8010) + - "traefik.http.routers.ess-wellknown.rule=Host(`{{ ess_server_name }}`) && PathPrefix(`/.well-known/matrix`)" + - "traefik.http.routers.ess-wellknown.entrypoints={{ ess_compose_traefik_entrypoint }}" + - "traefik.http.routers.ess-wellknown.tls=true" +{% if ess_compose_traefik_certresolver | length > 0 %} + - "traefik.http.routers.ess-wellknown.tls.certresolver={{ ess_compose_traefik_certresolver }}" +{% endif %} + - "traefik.http.routers.ess-wellknown.service=ess-wellknown" + - "traefik.http.services.ess-wellknown.loadbalancer.server.port=8010" + + # =========================================================================== + # Element Web (browser client) + # =========================================================================== + + element-web: + image: {{ ess_images.element_web }} + container_name: element-web + restart: unless-stopped + networks: [ {{ ess_compose_traefik_network }} ] + volumes: + - {{ ess_compose_conf_dir }}/element-web/config.json:/app/config.json:ro + labels: + - "traefik.enable=true" + - "traefik.docker.network={{ ess_compose_traefik_network }}" + - "traefik.http.routers.ess-element-web.rule=Host(`{{ ess_hostnames.element_web }}`)" + - "traefik.http.routers.ess-element-web.entrypoints={{ ess_compose_traefik_entrypoint }}" + - "traefik.http.routers.ess-element-web.tls=true" +{% if ess_compose_traefik_certresolver | length > 0 %} + - "traefik.http.routers.ess-element-web.tls.certresolver={{ ess_compose_traefik_certresolver }}" +{% endif %} + - "traefik.http.services.ess-element-web.loadbalancer.server.port=8080" + + # =========================================================================== + # Element Admin (admin panel) + # =========================================================================== + + element-admin: + image: {{ ess_images.element_admin }} + container_name: element-admin + restart: unless-stopped + networks: [ {{ ess_compose_traefik_network }} ] + environment: + SERVER_NAME: "{{ ess_server_name }}" + labels: + - "traefik.enable=true" + - "traefik.docker.network={{ ess_compose_traefik_network }}" + - "traefik.http.routers.ess-element-admin.rule=Host(`{{ ess_hostnames.element_admin }}`)" + - "traefik.http.routers.ess-element-admin.entrypoints={{ ess_compose_traefik_entrypoint }}" + - "traefik.http.routers.ess-element-admin.tls=true" +{% if ess_compose_traefik_certresolver | length > 0 %} + - "traefik.http.routers.ess-element-admin.tls.certresolver={{ ess_compose_traefik_certresolver }}" +{% endif %} + - "traefik.http.services.ess-element-admin.loadbalancer.server.port=8080" + + # =========================================================================== + # Matrix RTC / Element Call (LiveKit SFU + lk-jwt) + # =========================================================================== + + matrix-rtc-sfu: + image: {{ ess_images.livekit }} + container_name: matrix-rtc-sfu + restart: unless-stopped + networks: + - {{ ess_compose_internal_network }} + - {{ ess_compose_traefik_network }} + command: ["--config", "/conf/sfu-config.yaml"] + volumes: + - {{ ess_compose_conf_dir }}/sfu/config.yaml:/conf/sfu-config.yaml:ro + # WebRTC media ports — DMZ firewall must NAT-forward these to this host. + ports: + - "{{ ess_rtc_tcp_port }}:{{ ess_rtc_tcp_port }}/tcp" + - "{{ ess_rtc_udp_port }}:{{ ess_rtc_udp_port }}/udp" + labels: + - "traefik.enable=true" + - "traefik.docker.network={{ ess_compose_traefik_network }}" + - "traefik.http.routers.ess-matrix-rtc.rule=Host(`{{ ess_hostnames.matrix_rtc }}`)" + - "traefik.http.routers.ess-matrix-rtc.entrypoints={{ ess_compose_traefik_entrypoint }}" + - "traefik.http.routers.ess-matrix-rtc.tls=true" +{% if ess_compose_traefik_certresolver | length > 0 %} + - "traefik.http.routers.ess-matrix-rtc.tls.certresolver={{ ess_compose_traefik_certresolver }}" +{% endif %} + - "traefik.http.routers.ess-matrix-rtc.service=ess-matrix-rtc" + - "traefik.http.services.ess-matrix-rtc.loadbalancer.server.port=7880" + + matrix-rtc-authorisation: + image: {{ ess_images.lk_jwt }} + container_name: matrix-rtc-authorisation + restart: unless-stopped + depends_on: + matrix-rtc-sfu: { condition: service_started } + networks: + - {{ ess_compose_internal_network }} + - {{ ess_compose_traefik_network }} + environment: + LIVEKIT_URL: "wss://{{ ess_hostnames.matrix_rtc }}" + LIVEKIT_KEY: "{{ ess_livekit_key }}" + LIVEKIT_SECRET_FROM_FILE: /secrets/ess-generated/ELEMENT_CALL_LIVEKIT_SECRET + LIVEKIT_FULL_ACCESS_HOMESERVERS: "{{ ess_server_name }}" + volumes: + - {{ ess_compose_secrets_dir }}:/secrets/ess-generated:ro + labels: + # /sfu/get is the JWT token endpoint Element Call hits to join calls. + # It lives on the same host as the SFU but on a different backend. + - "traefik.enable=true" + - "traefik.docker.network={{ ess_compose_traefik_network }}" + - "traefik.http.routers.ess-matrix-rtc-auth.rule=Host(`{{ ess_hostnames.matrix_rtc }}`) && PathPrefix(`/sfu/get`)" + - "traefik.http.routers.ess-matrix-rtc-auth.entrypoints={{ ess_compose_traefik_entrypoint }}" + - "traefik.http.routers.ess-matrix-rtc-auth.tls=true" + - "traefik.http.routers.ess-matrix-rtc-auth.priority=200" +{% if ess_compose_traefik_certresolver | length > 0 %} + - "traefik.http.routers.ess-matrix-rtc-auth.tls.certresolver={{ ess_compose_traefik_certresolver }}" +{% endif %} + - "traefik.http.routers.ess-matrix-rtc-auth.service=ess-matrix-rtc-auth" + - "traefik.http.services.ess-matrix-rtc-auth.loadbalancer.server.port=8080" diff --git a/roles/ess_pro_compose/templates/element-web/config.json.j2 b/roles/ess_pro_compose/templates/element-web/config.json.j2 new file mode 100644 index 0000000..896add8 --- /dev/null +++ b/roles/ess_pro_compose/templates/element-web/config.json.j2 @@ -0,0 +1,33 @@ +{ + "bug_report_endpoint_url": "local", + "default_server_config": { + "m.homeserver": { + "base_url": "https://{{ ess_hostnames.synapse }}", + "server_name": "{{ ess_server_name }}" + } + }, + "element_call": { + "use_exclusively": true + }, + "embedded_pages": { + "login_for_welcome": true + }, + "features": { + "feature_element_call_video_rooms": true, + "feature_group_calls": true, + "feature_new_room_decoration_ui": true, + "feature_video_rooms": true + }, + "mobile_guide_app_variant": "element-pro", + "setting_defaults": { + "UIFeature.deactivate": false, + "UIFeature.passwordReset": false, + "UIFeature.registration": {{ ess_enable_registration | bool | lower }}, + "feature_group_calls": true, + "urlPreviewsEnabled": {{ ess_synapse_url_previews_enabled | bool | lower }}, + "urlPreviewsEnabled_e2ee": {{ ess_synapse_url_previews_enabled | bool | lower }} + }, + "sso_redirect_options": { + "immediate": false + } +} diff --git a/roles/ess_pro_compose/templates/generate-secrets.py.j2 b/roles/ess_pro_compose/templates/generate-secrets.py.j2 new file mode 100644 index 0000000..a652d63 --- /dev/null +++ b/roles/ess_pro_compose/templates/generate-secrets.py.j2 @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# {{ ansible_managed }} +""" +Generate the ess-generated secret bundle the way the Helm chart's +init-secrets job does. Idempotent: only writes files that don't exist. + +Mirrors `matrix-tools generate-secrets` arguments from chart v{{ ess_chart_version }}. +""" +import os +import secrets +import sys +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa + +SECRETS_DIR = Path("{{ ess_compose_secrets_dir }}") +SECRETS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) + + +def write_if_missing(name, content_bytes): + p = SECRETS_DIR / name + if p.exists(): + return False + # Atomic-ish write + tmp = p.with_suffix(p.suffix + ".tmp") + tmp.write_bytes(content_bytes) + os.chmod(tmp, 0o600) + tmp.rename(p) + return True + + +def rand32(): + # `matrix-tools rand32` produces 32 url-safe characters + return secrets.token_urlsafe(24)[:32].encode() + + +def hex32(): + return secrets.token_hex(32).encode() + + +def rsa_der(): + key = rsa.generate_private_key(public_exponent=65537, key_size=4096) + return key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +def ecdsa_prime256v1(): + key = ec.generate_private_key(ec.SECP256R1()) + return key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +def synapse_signing_key(): + # Synapse expects: ed25519 + import base64 + key = ed25519.Ed25519PrivateKey.generate() + seed = key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption(), + ) + # 4-char keyid like Synapse generates + keyid = secrets.token_hex(2) + b64 = base64.b64encode(seed).rstrip(b"=").decode() + return f"ed25519 a_{keyid} {b64}\n".encode() + + +SPEC = { + "POSTGRES_ADMIN_PASSWORD": rand32, + "POSTGRES_SYNAPSE_PASSWORD": rand32, + "POSTGRES_MATRIX_AUTHENTICATION_SERVICE_PASSWORD": rand32, + "SYNAPSE_MACAROON": rand32, + "SYNAPSE_REGISTRATION_SHARED_SECRET": rand32, + "SYNAPSE_WORKERS_REPLICATION_SECRET": rand32, + "SYNAPSE_SIGNING_KEY": synapse_signing_key, + "MAS_SYNAPSE_SHARED_SECRET": rand32, + "MAS_MATRIX_TOOLS_OIDC_CLIENT_SECRET": rand32, + "MAS_ENCRYPTION_SECRET": hex32, + "MAS_RSA_PRIVATE_KEY": rsa_der, + "MAS_ECDSA_PRIME256V1_PRIVATE_KEY": ecdsa_prime256v1, + "ELEMENT_CALL_LIVEKIT_SECRET": rand32, + "ADMIN_USER_PASSWORD": rand32, +} + + +created = [] +for name, fn in SPEC.items(): + if write_if_missing(name, fn()): + created.append(name) + +if created: + print("CREATED:", " ".join(created)) +else: + print("NOCHANGE") +sys.exit(0) diff --git a/roles/ess_pro_compose/templates/haproxy/429.http.j2 b/roles/ess_pro_compose/templates/haproxy/429.http.j2 new file mode 100644 index 0000000..ae0b5b7 --- /dev/null +++ b/roles/ess_pro_compose/templates/haproxy/429.http.j2 @@ -0,0 +1,9 @@ +HTTP/1.0 429 Too Many Requests +Cache-Control: no-cache +Connection: close +Content-Type: application/json +access-control-allow-origin: * +access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS +access-control-allow-headers: Origin, X-Requested-With, Content-Type, Accept, Authorization + +{"errcode":"M_UNKNOWN","error":"Server is unavailable"} diff --git a/roles/ess_pro_compose/templates/haproxy/admin-allow-ips.lst.j2 b/roles/ess_pro_compose/templates/haproxy/admin-allow-ips.lst.j2 new file mode 100644 index 0000000..505dfac --- /dev/null +++ b/roles/ess_pro_compose/templates/haproxy/admin-allow-ips.lst.j2 @@ -0,0 +1,4 @@ +# {{ ansible_managed }} +{% for cidr in ess_admin_allow_ips %} +{{ cidr }} +{% endfor %} diff --git a/roles/ess_pro_compose/templates/haproxy/haproxy.cfg.j2 b/roles/ess_pro_compose/templates/haproxy/haproxy.cfg.j2 new file mode 100644 index 0000000..ad8a570 --- /dev/null +++ b/roles/ess_pro_compose/templates/haproxy/haproxy.cfg.j2 @@ -0,0 +1,177 @@ +# {{ ansible_managed }} +# Adapted from ess-helm chart {{ ess_chart_version }} (ess-haproxy ConfigMap). +# K8s DNS-SRV-based service discovery replaced with direct compose hostnames. + +global + maxconn 20000 + log stdout format raw local0 info + tune.maxrewrite 4096 + stats socket ipv4@127.0.0.1:1999 level admin + dns-accept-family ipv4 + +defaults + mode http + fullconn 10000 + maxconn 10000 + log global + option forwardfor if-none + option forwarded + timeout connect 5s + timeout queue 60s + timeout client 900s + timeout http-keep-alive 900s + timeout http-request 10s + timeout server 180s + http-reuse aggressive + default-server maxconn 500 + option redispatch + compression algo gzip + compression type text/plain text/html text/xml application/json text/css + hash-type consistent sdbm + +# Compose resolves service names via the embedded DNS (127.0.0.11). We point +# HAProxy at it so backend health-checks pick up restarts properly. +resolvers compose-dns + nameserver dns1 127.0.0.11:53 + accepted_payload_size 8192 + hold timeout 600s + hold refused 600s + +frontend prometheus + bind *:8405 + http-request use-service prometheus-exporter if { path /metrics } + monitor-uri /haproxy_test + no log + +frontend http-blackhole + bind *:8009 + http-request deny content-type application/json string '{"errcode": "M_FORBIDDEN", "error": "Blocked"}' + +frontend startup + bind *:8406 + acl synapse_dead nbsrv(synapse-main) lt 1 + monitor-uri /synapse_ready + monitor fail if synapse_dead + +# ---------------------------------------------------------------------------- +# Synapse traffic — main entrypoint that the DMZ Traefik points at for matrix.* +# ---------------------------------------------------------------------------- +frontend synapse-http-in + bind *:8008 + errorfile 503 /synapse/429.http + http-request capture hdr(host) len 32 + http-request capture req.fhdr(x-forwarded-for) len 64 + http-request capture req.fhdr(user-agent) len 200 + + http-request set-header X-Forwarded-Proto https if !{ hdr(X-Forwarded-Proto) -m found } + http-request set-var(txn.x_forwarded_proto) hdr(x-forwarded-proto) + http-response add-header Strict-Transport-Security max-age=31536000 if { var(txn.x_forwarded_proto) -m str -i "https" } + + # Access token extraction (used by upstream rate-limit decisions) + http-request set-var(req.access_token) urlp("access_token") if { urlp("access_token") -m found } + http-request set-var(req.access_token) req.fhdr(Authorization),word(2," ") if { hdr_beg("Authorization") -i "Bearer " } + http-request set-header X-Access-Token %[var(req.access_token)] + + http-response set-header Permissions-Policy "interest-cohort=()" + + # Admin endpoint IP allow-list + acl is_admin path_reg ^/_synapse/admin/.* + http-request set-var(txn.user_ip) req.fhdr(x-forwarded-for) if { hdr(x-forwarded-for) -m found } + http-request set-var(txn.user_ip) src if !{ hdr(x-forwarded-for) -m found } + acl allow_ip_admin var(txn.user_ip) -m ip -f /synapse/admin-allow-ips.lst + http-request deny if !allow_ip_admin is_admin + + # FOSS-worker path maps (empty by default; reserved for advanced worker splits) + acl has_get_map path -m reg -M -f /synapse/path_map_file_get + http-request set-var(req.backend) path,map_reg(/synapse/path_map_file_get,main) if has_get_map METH_GET + http-request set-var(req.backend) path,map_reg(/synapse/path_map_file,main) unless { var(req.backend) -m found } + + # Pro federation-reader worker: takes /event, /state, /state_ids reads + acl has_available_pro_fed nbsrv('synapse-pro-federation-api-requests') ge 1 + http-request set-var(req.backend) str('pro-federation-api-requests') if has_available_pro_fed { path -m reg ^/_matrix/federation/v1/event/ } + http-request set-var(req.backend) str('pro-federation-api-requests') if has_available_pro_fed { path -m reg ^/_matrix/federation/v1/state/ } + http-request set-var(req.backend) str('pro-federation-api-requests') if has_available_pro_fed { path -m reg ^/_matrix/federation/v1/state_ids/ } + + # CORS preflight short-circuits + acl rendezvous path_beg /_matrix/client/unstable/org.matrix.msc4108/rendezvous + acl rendezvous path_beg /_synapse/client/rendezvous + use_backend return_204_rendezvous if { method OPTIONS } rendezvous + use_backend return_204_synapse if { method OPTIONS } + + # Failover from pro-fed-reader to main if the worker is unavailable + acl has_failover var(req.backend) -m str "pro-federation-api-requests" + acl backend_unavailable str(),concat('synapse-',req.backend),nbsrv lt 1 + use_backend synapse-main-failover if has_failover backend_unavailable + + use_backend synapse-%[var(req.backend)] + +backend synapse-main + default-server maxconn 250 + option httpchk + http-check connect port 8080 + http-check send meth GET uri /health + server main synapse-main:8008 check port 8080 resolvers compose-dns + +backend synapse-main-failover + default-server maxconn 250 + option httpchk + http-check connect port 8080 + http-check send meth GET uri /health + server main synapse-main:8008 check port 8080 resolvers compose-dns + +backend synapse-pro-federation-api-requests + option httpchk + http-check connect port 8008 + http-check send meth GET uri /health/alive + balance uri whole + # The federation-reader worker is a Rust service speaking h2c. +{% for i in range(ess_synapse_fed_reader_replicas | int) %} + server fed-reader-{{ i }} synapse-fed-reader-{{ i }}:8008 check resolvers compose-dns proto h2 +{% endfor %} + +backend return_204_synapse + http-request return status 204 hdr "Access-Control-Allow-Origin" "*" hdr "Access-Control-Allow-Methods" "GET, HEAD, POST, PUT, DELETE, OPTIONS" hdr "Access-Control-Allow-Headers" "Origin, X-Requested-With, Content-Type, Accept, Authorization, Date" hdr "Access-Control-Expose-Headers" "Synapse-Trace-Id, Server" + +backend return_204_rendezvous + http-request return status 204 hdr "Access-Control-Allow-Origin" "*" hdr "Access-Control-Allow-Methods" "GET, HEAD, POST, PUT, DELETE, OPTIONS" hdr "Access-Control-Allow-Headers" "Origin, Content-Type, Accept, Content-Type, If-Match, If-None-Match" hdr "Access-Control-Expose-Headers" "Synapse-Trace-Id, Server, ETag" + +# ---------------------------------------------------------------------------- +# Well-known — served at the apex domain via the same HAProxy. +# DMZ Traefik routes Host=`{{ ess_server_name }}` && PathPrefix(/.well-known) here. +# ---------------------------------------------------------------------------- +frontend well-known-in + bind *:8010 + acl is_delete_put_post_method method DELETE POST PUT + http-request deny status 405 if is_delete_put_post_method + + acl well-known path /.well-known/matrix/server + acl well-known path /.well-known/matrix/client + acl well-known path /.well-known/matrix/support + acl well-known path /.well-known/element/element.json + http-request redirect code 301 location https://{{ ess_hostnames.element_web }} unless well-known + + use_backend well-known-static if well-known + default_backend well-known-no-match + +backend well-known-static + mode http + http-after-response set-header X-Frame-Options SAMEORIGIN + http-after-response set-header X-Content-Type-Options nosniff + http-after-response set-header X-XSS-Protection "1; mode=block" + http-after-response set-header Content-Security-Policy "frame-ancestors 'self'" + http-after-response set-header X-Robots-Tag "noindex, nofollow, noarchive, noimageindex" + http-after-response set-header Access-Control-Allow-Origin * + http-after-response set-header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" + http-after-response set-header Access-Control-Allow-Headers "X-Requested-With, Content-Type, Authorization" + + http-request return status 200 content-type "application/json" file "/well-known/server" if { path /.well-known/matrix/server } + http-request return status 200 content-type "application/json" file "/well-known/client" if { path /.well-known/matrix/client } + http-request return status 200 content-type "application/json" file "/well-known/support" if { path /.well-known/matrix/support } + http-request return status 200 content-type "application/json" file "/well-known/element.json" if { path /.well-known/element/element.json } + +backend well-known-no-match + mode http + http-request deny status 404 + +backend return_500 + http-request deny deny_status 500 diff --git a/roles/ess_pro_compose/templates/haproxy/path_map_file.j2 b/roles/ess_pro_compose/templates/haproxy/path_map_file.j2 new file mode 100644 index 0000000..e43b2e4 --- /dev/null +++ b/roles/ess_pro_compose/templates/haproxy/path_map_file.j2 @@ -0,0 +1,5 @@ +# {{ ansible_managed }} +# Map matrix paths to worker backends. Format: path_regexp backend_name +# Chart default: empty (no FOSS-worker splits). Reserved for advanced +# worker topologies; the Pro federation-reader routing is hard-coded in +# haproxy.cfg via the synapse-pro-federation-api-requests backend. diff --git a/roles/ess_pro_compose/templates/haproxy/path_map_file_get.j2 b/roles/ess_pro_compose/templates/haproxy/path_map_file_get.j2 new file mode 100644 index 0000000..f99c228 --- /dev/null +++ b/roles/ess_pro_compose/templates/haproxy/path_map_file_get.j2 @@ -0,0 +1,2 @@ +# {{ ansible_managed }} +# GET-only worker path map. See path_map_file for context. diff --git a/roles/ess_pro_compose/templates/haproxy/well-known/client.j2 b/roles/ess_pro_compose/templates/haproxy/well-known/client.j2 new file mode 100644 index 0000000..858b974 --- /dev/null +++ b/roles/ess_pro_compose/templates/haproxy/well-known/client.j2 @@ -0,0 +1,11 @@ +{ + "m.homeserver": { + "base_url": "https://{{ ess_hostnames.synapse }}" + }, + "org.matrix.msc4143.rtc_foci": [ + { + "livekit_service_url": "https://{{ ess_hostnames.matrix_rtc }}", + "type": "livekit" + } + ] +} diff --git a/roles/ess_pro_compose/templates/haproxy/well-known/element.json.j2 b/roles/ess_pro_compose/templates/haproxy/well-known/element.json.j2 new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/roles/ess_pro_compose/templates/haproxy/well-known/element.json.j2 @@ -0,0 +1 @@ +{} diff --git a/roles/ess_pro_compose/templates/haproxy/well-known/server.j2 b/roles/ess_pro_compose/templates/haproxy/well-known/server.j2 new file mode 100644 index 0000000..9d175c8 --- /dev/null +++ b/roles/ess_pro_compose/templates/haproxy/well-known/server.j2 @@ -0,0 +1 @@ +{"m.server": "{{ ess_hostnames.synapse }}:443"} diff --git a/roles/ess_pro_compose/templates/haproxy/well-known/support.j2 b/roles/ess_pro_compose/templates/haproxy/well-known/support.j2 new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/roles/ess_pro_compose/templates/haproxy/well-known/support.j2 @@ -0,0 +1 @@ +{} diff --git a/roles/ess_pro_compose/templates/mas/config.yaml.j2 b/roles/ess_pro_compose/templates/mas/config.yaml.j2 new file mode 100644 index 0000000..bbc0948 --- /dev/null +++ b/roles/ess_pro_compose/templates/mas/config.yaml.j2 @@ -0,0 +1,114 @@ +## {{ ansible_managed }} +## Matrix Authentication Service — merged from chart fragments. +## Adapted from ess-helm {{ ess_chart_version }} for docker compose. + +http: + public_base: "https://{{ ess_hostnames.mas }}/" + issuer: "https://{{ ess_hostnames.mas }}/" + listeners: + # Public web UI + OAuth + GraphQL + admin API. Fronted by DMZ Traefik. + - name: web + binds: + - host: 0.0.0.0 + port: 8080 + resources: + - name: human + - name: oauth + - name: assets + - name: graphql + undocumented_oauth2_access: true + - name: adminapi + # Internal — never exposed publicly. Used for healthchecks and metrics. + - name: internal + binds: + - host: 0.0.0.0 + port: 8081 + resources: + - name: health + - name: prometheus + - name: connection-info + # Root domain — serves .well-known/openid-configuration et al. on + # https://{{ ess_hostnames.mas }} root. Mounted as the public listener + # since DMZ Traefik strips paths. + - name: root + binds: + - host: 0.0.0.0 + port: 8082 + resources: + - name: discovery + - name: compat + # Talks to Synapse on the internal network only. + - name: synapse + binds: + - host: 0.0.0.0 + port: 8083 + resources: + - name: discovery + - name: oauth + +database: + uri: "postgresql://matrixauthenticationservice_user:{{ _ess_secrets.POSTGRES_MATRIX_AUTHENTICATION_SERVICE_PASSWORD }}@postgres:5432/matrixauthenticationservice?sslmode=prefer&application_name=matrix-authentication-service" + +telemetry: + metrics: + exporter: prometheus + +matrix: + homeserver: "{{ ess_server_name }}" + secret_file: {{ _ess_secret_mount }}/MAS_SYNAPSE_SHARED_SECRET + endpoint: "http://synapse-main:8008" + kind: synapse_modern + +# ---- OAuth2 clients ------------------------------------------------------- +clients: + # Matrix-tools admin client used by mas-cli operations. + - client_id: "000000000000000MATR1XT001S" + client_auth_method: client_secret_basic + client_secret_file: {{ _ess_secret_mount }}/MAS_MATRIX_TOOLS_OIDC_CLIENT_SECRET + +# ---- Signing keys & encryption (file-mounted) ---------------------------- +secrets: + encryption_file: {{ _ess_secret_mount }}/MAS_ENCRYPTION_SECRET + keys: + - key_file: {{ _ess_secret_mount }}/MAS_RSA_PRIVATE_KEY + - key_file: {{ _ess_secret_mount }}/MAS_ECDSA_PRIME256V1_PRIVATE_KEY + +# ---- Policy --------------------------------------------------------------- +policy: + data: + admin_clients: + - "000000000000000MATR1XT001S" + admin_users: [] + client_registration: + allow_host_mismatch: false + allow_insecure_uris: false + +account: + password_registration_enabled: {{ ess_enable_registration | bool | lower }} + +passwords: + enabled: true + +{% if ess_oidc_enabled %} +# ---- Upstream OIDC (Authentik for demo, Keycloak for prod) ---------------- +upstream_oauth2: + providers: + - id: "{{ ess_oidc_provider_ulid }}" + human_name: "{{ ess_oidc_provider_name }}" + issuer: "{{ ess_oidc_issuer }}" + client_id: "{{ ess_oidc_client_id }}" + client_secret: "{{ ess_oidc_client_secret }}" + token_endpoint_auth_method: client_secret_basic + scope: "{{ ess_oidc_scopes }}" + claims_imports: + localpart: + action: require + template: "{{ '{{ user.preferred_username }}' }}" + displayname: + action: suggest + template: "{{ '{{ user.name }}' }}" + email: + action: suggest + template: "{{ '{{ user.email }}' }}" + set_email_verification: always +{% endif %} diff --git a/roles/ess_pro_compose/templates/postgres/configure-dbs.sh.j2 b/roles/ess_pro_compose/templates/postgres/configure-dbs.sh.j2 new file mode 100644 index 0000000..c078e66 --- /dev/null +++ b/roles/ess_pro_compose/templates/postgres/configure-dbs.sh.j2 @@ -0,0 +1,30 @@ +#!/bin/sh +# {{ ansible_managed }} +# Postgres init script — chart-equivalent of configure-dbs.sh. +# Reads password files from /secrets/ess-generated and creates two DBs. + +set -e + +create_or_ensure_db() { + user="$1" + db="$2" + password="$3" + admin_password="$4" + + if echo -n "$admin_password" | psql -W -U postgres -tc "SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = '$user'" | grep -q 1; then + echo -n "$admin_password" | psql -W -U postgres -c "ALTER USER $user PASSWORD '$password'" + else + echo -n "$admin_password" | psql -W -U postgres -c "CREATE ROLE $user LOGIN PASSWORD '$password'" + fi + + if ! echo -n "$admin_password" | psql -W -U postgres -tc "SELECT 1 FROM pg_database WHERE datname = '$db'" | grep -q 1; then + echo -n "$admin_password" | createdb --encoding=UTF8 --locale=C --template=template0 --owner=$user $db -U postgres + fi +} + +POSTGRES_PASSWORD="$(cat /secrets/ess-generated/POSTGRES_ADMIN_PASSWORD)" +ESS_SYNAPSE_PW="$(cat /secrets/ess-generated/POSTGRES_SYNAPSE_PASSWORD)" +ESS_MAS_PW="$(cat /secrets/ess-generated/POSTGRES_MATRIX_AUTHENTICATION_SERVICE_PASSWORD)" + +create_or_ensure_db "matrixauthenticationservice_user" "matrixauthenticationservice" "$ESS_MAS_PW" "$POSTGRES_PASSWORD" +create_or_ensure_db "synapse_user" "synapse" "$ESS_SYNAPSE_PW" "$POSTGRES_PASSWORD" diff --git a/roles/ess_pro_compose/templates/redis/redis.conf.j2 b/roles/ess_pro_compose/templates/redis/redis.conf.j2 new file mode 100644 index 0000000..e0dcc65 --- /dev/null +++ b/roles/ess_pro_compose/templates/redis/redis.conf.j2 @@ -0,0 +1,27 @@ +# {{ ansible_managed }} +# Redis config — adapted from ess-helm {{ ess_chart_version }}. Used as +# pub/sub for Synapse worker replication; no persistence needed. + +protected-mode no +port 6379 +tcp-backlog 511 +tcp-keepalive 300 +timeout 0 +daemonize no +supervised no +loglevel notice +logfile '' +databases 16 +always-show-logo no +stop-writes-on-bgsave-error yes +save '' + +# Disable persistence — Synapse uses Redis only for pub/sub between workers. +appendonly no + +maxmemory 256mb +maxmemory-policy allkeys-lru + +hz 1 +dynamic-hz yes +jemalloc-bg-thread yes diff --git a/roles/ess_pro_compose/templates/sfu/config.yaml.j2 b/roles/ess_pro_compose/templates/sfu/config.yaml.j2 new file mode 100644 index 0000000..eef9100 --- /dev/null +++ b/roles/ess_pro_compose/templates/sfu/config.yaml.j2 @@ -0,0 +1,32 @@ +## {{ ansible_managed }} +## LiveKit SFU — adapted from ess-helm {{ ess_chart_version }}. + +port: 7880 + +prometheus: + port: 6789 + +logging: + level: info + pion_level: error + json: false + +rtc: + use_external_ip: false + tcp_port: {{ ess_rtc_tcp_port }} + udp_port: {{ ess_rtc_udp_port }} + # Public IP that LiveKit advertises in ICE candidates. The DMZ NAT forwards + # {{ ess_rtc_tcp_port }}/TCP and {{ ess_rtc_udp_port }}/UDP to this host. + node_ip: "{{ ess_rtc_external_ip }}" + +# Keys are embedded directly (rendered at compose-up time). The single key +# `{{ ess_livekit_key }}` matches what the authorisation service issues +# tokens against. +keys: + {{ ess_livekit_key }}: "{{ _ess_secrets.ELEMENT_CALL_LIVEKIT_SECRET }}" + +room: + auto_create: false + +turn: + enabled: false diff --git a/roles/ess_pro_compose/templates/synapse/federation-reader.yaml.j2 b/roles/ess_pro_compose/templates/synapse/federation-reader.yaml.j2 new file mode 100644 index 0000000..8b78401 --- /dev/null +++ b/roles/ess_pro_compose/templates/synapse/federation-reader.yaml.j2 @@ -0,0 +1,23 @@ +## {{ ansible_managed }} +## synapse-pro-worker (Rust) federation reader. +## This is a different config schema than Python Synapse. + +http: + bind_addr: "::" + bind_port: 8008 + +metrics: + bind_addr: "::" + bind_port: 9001 + +synapse: + server_name: "{{ ess_server_name }}" + +database: + connection_string: "postgresql://synapse_user:{{ _ess_secrets.POSTGRES_SYNAPSE_PASSWORD }}@postgres:5432/synapse?sslmode=prefer" + +redis: + host: redis + port: 6379 + +logging: basic diff --git a/roles/ess_pro_compose/templates/synapse/homeserver.yaml.j2 b/roles/ess_pro_compose/templates/synapse/homeserver.yaml.j2 new file mode 100644 index 0000000..de81d39 --- /dev/null +++ b/roles/ess_pro_compose/templates/synapse/homeserver.yaml.j2 @@ -0,0 +1,159 @@ +## {{ ansible_managed }} +## Synapse homeserver config — merged from chart fragments +## 01-homeserver-underrides + 04-homeserver-overrides + 05-main. +## Adapted from ess-helm {{ ess_chart_version }} for docker compose. + +server_name: "{{ ess_server_name }}" +public_baseurl: "https://{{ ess_hostnames.synapse }}/" +web_client_location: "https://{{ ess_hostnames.element_web }}/" +admin_contact: "{{ ess_admin_contact }}" + +pid_file: /data/homeserver.pid +signing_key_path: {{ _ess_secret_mount }}/SYNAPSE_SIGNING_KEY +macaroon_secret_key_path: {{ _ess_secret_mount }}/SYNAPSE_MACAROON +registration_shared_secret_path: {{ _ess_secret_mount }}/SYNAPSE_REGISTRATION_SHARED_SECRET +worker_replication_secret_path: {{ _ess_secret_mount }}/SYNAPSE_WORKERS_REPLICATION_SECRET + +log_config: "/conf/log_config.yaml" +enable_metrics: true +report_stats: false + +# ---- Listeners (from 05-main.yaml) ---------------------------------------- +listeners: + - port: 8008 + tls: false + type: http + bind_addresses: ['0.0.0.0', '::'] + x_forwarded: true + resources: + - names: [client, federation] + compress: false + - port: 9093 + tls: false + type: http + bind_addresses: ['0.0.0.0', '::'] + x_forwarded: false + resources: + - names: [replication] + compress: false + - port: 8080 + tls: false + type: http + bind_addresses: ['0.0.0.0', '::'] + x_forwarded: false + resources: + - names: [health] + compress: false + - type: metrics + port: 9001 + bind_addresses: ['::'] + +enable_media_repo: true +media_store_path: "/media/media_store" +max_upload_size: "{{ ess_synapse_max_upload_size }}" + +# ---- Pro modules ---------------------------------------------------------- +modules: + - module: "synapse_ess_pro.EssPro" + config: + version_path: /ess/version + - module: "synapse_mass_local_room_upgrades.MassLocalRoomUpgradesModule" + config: {} + +# ---- Database ------------------------------------------------------------- +database: + name: psycopg2 + args: + user: synapse_user + password: "{{ _ess_secrets.POSTGRES_SYNAPSE_PASSWORD }}" + dbname: synapse + host: postgres + port: 5432 + sslmode: prefer + keepalives: 1 + keepalives_idle: 10 + keepalives_interval: 10 + keepalives_count: 3 + cp_min: 5 + cp_max: 10 + +# ---- Redis (required for workers) ----------------------------------------- +redis: + enabled: true + host: redis + port: 6379 + +# Replication topology — fed-reader connects back to the main on 9093. +instance_map: + main: + host: synapse-main + port: 9093 + +# ---- Matrix 2.0 features (MSC4108 QR login, MSC4222 syncv2, MSC4143 RTC) -- +experimental_features: + msc4143_enabled: true + msc4222_enabled: true + msc4108_enabled: true + msc4028_push_encrypted_events: true + +# ---- Delegated auth to MAS (stable since Synapse 1.118) ------------------- +matrix_authentication_service: + enabled: true + secret_path: {{ _ess_secret_mount }}/MAS_SYNAPSE_SHARED_SECRET + endpoint: "http://mas:8083/" + force_http2: true + +password_config: + localdb_enabled: false + enabled: false + +# ---- Matrix RTC (Element Call discovery) ---------------------------------- +matrix_rtc: + transports: + - type: livekit + livekit_service_url: "https://{{ ess_hostnames.matrix_rtc }}" + +# ---- URL previews --------------------------------------------------------- +url_preview_enabled: {{ ess_synapse_url_previews_enabled | bool | lower }} +url_preview_ip_range_whitelist: [] +url_preview_ip_range_blacklist: + - '127.0.0.0/8' + - '10.0.0.0/8' + - '172.16.0.0/12' + - '192.168.0.0/16' + - '100.64.0.0/10' + - '169.254.0.0/16' + - '::1/128' + - 'fe80::/10' + - 'fc00::/7' + +# ---- Federation ----------------------------------------------------------- +{% if ess_enable_federation %} +send_federation: true +federation_client_minimum_tls_version: '1.2' +{% else %} +send_federation: false +federation_domain_whitelist: [] +{% endif %} + +# ---- Other defaults from chart underrides --------------------------------- +require_auth_for_profile_requests: true +presence: + enabled: false +start_pushers: true +max_event_delay_duration: 24h + +room_list_publication_rules: + - action: allow + user_id: "@*:{{ ess_server_name }}" + +rc_message: + per_second: 0.5 + burst_count: 30 +rc_delayed_event_mgmt: + per_second: 1 + burst_count: 20 + +trusted_key_servers: + - server_name: "matrix.org" +suppress_key_server_warning: true diff --git a/roles/ess_pro_compose/templates/synapse/log_config.yaml.j2 b/roles/ess_pro_compose/templates/synapse/log_config.yaml.j2 new file mode 100644 index 0000000..5d1ade7 --- /dev/null +++ b/roles/ess_pro_compose/templates/synapse/log_config.yaml.j2 @@ -0,0 +1,16 @@ +## {{ ansible_managed }} +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: + level: INFO +root: + level: INFO + handlers: [console] +disable_existing_loggers: false diff --git a/roles/ess_pro_compose/vars/main.yml b/roles/ess_pro_compose/vars/main.yml new file mode 100644 index 0000000..e995a26 --- /dev/null +++ b/roles/ess_pro_compose/vars/main.yml @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: MIT-0 +--- +# Internal — do not override in inventory. + +# Mount points inside containers (Element Pro convention) +_ess_secret_mount: "/secrets/ess-generated" +_ess_conf_mount: "/conf" +_ess_well_known_mount: "/well-known" + +# Compose file path +_ess_compose_file: "{{ ess_compose_dir }}/compose.yml" +_ess_env_file: "{{ ess_compose_dir }}/.env" + +# Directory tree to create on the host +_ess_dirs: + - "{{ ess_compose_dir }}" + - "{{ ess_compose_conf_dir }}" + - "{{ ess_compose_conf_dir }}/haproxy" + - "{{ ess_compose_conf_dir }}/haproxy/well-known" + - "{{ ess_compose_conf_dir }}/synapse" + - "{{ ess_compose_conf_dir }}/mas" + - "{{ ess_compose_conf_dir }}/sfu" + - "{{ ess_compose_conf_dir }}/element-web" + - "{{ ess_compose_conf_dir }}/postgres" + - "{{ ess_compose_conf_dir }}/redis" + - "{{ ess_compose_secrets_dir }}" + - "{{ ess_compose_data_dir }}" + - "{{ ess_compose_data_dir }}/postgres" + - "{{ ess_compose_data_dir }}/synapse-media" + +# All Element Pro secret-file names (matches the init-secrets job in the chart) +_ess_secret_names: + - POSTGRES_ADMIN_PASSWORD + - POSTGRES_SYNAPSE_PASSWORD + - POSTGRES_MATRIX_AUTHENTICATION_SERVICE_PASSWORD + - SYNAPSE_MACAROON + - SYNAPSE_REGISTRATION_SHARED_SECRET + - SYNAPSE_WORKERS_REPLICATION_SECRET + - SYNAPSE_SIGNING_KEY + - MAS_SYNAPSE_SHARED_SECRET + - MAS_MATRIX_TOOLS_OIDC_CLIENT_SECRET + - MAS_ENCRYPTION_SECRET + - MAS_RSA_PRIVATE_KEY + - MAS_ECDSA_PRIME256V1_PRIVATE_KEY + - ELEMENT_CALL_LIVEKIT_SECRET + - ADMIN_USER_PASSWORD -- 2.49.1