diff --git a/playbooks/deploy.yaml b/playbooks/deploy.yaml
index 2a1498ac..fd86de91 100644
--- a/playbooks/deploy.yaml
+++ b/playbooks/deploy.yaml
@@ -110,6 +110,13 @@
tags:
- docker_compose
+- name: Ensure Jitsi deployment on jitsi_hosts
+ hosts: jitsi_hosts
+ roles:
+ - jitsi
+ tags:
+ - jitsi
+
- name: Ensure NGINX deployment on nginx_hosts
hosts: nginx_hosts:!public_reverse_proxy_hosts
roles:
diff --git a/roles/jitsi/README.md b/roles/jitsi/README.md
new file mode 100644
index 00000000..bd3db9bb
--- /dev/null
+++ b/roles/jitsi/README.md
@@ -0,0 +1,57 @@
+# Role `jitsi`
+
+A role for deploying [Jitsi Meet](https://jitsi.org/) using the official [docker-jitsi-meet](https://github.com/jitsi/docker-jitsi-meet) release.
+
+The given release is downloaded and unpacked unmodified to `/opt/jitsi/releases/` and `/opt/jitsi/release` is pointed to it.
+The `.env` file at `/opt/jitsi/.env` is fully managed by this role; `env.example` and `gen-passwords.sh` of the release are not used.
+The `.env` also sets `COMPOSE_PROJECT_NAME` and `COMPOSE_FILE`, so `docker compose` can be run by hand in `/opt/jitsi`.
+The Jitsi configuration directory (`CONFIG`) is `/opt/jitsi/config`, with persistent state in `/opt/jitsi/config/storage/`.
+The image version (`JITSI_IMAGE_VERSION`) always equals the release version, so upgrading means bumping `jitsi__version`.
+Releases other than the configured one are removed.
+
+TLS certificates are obtained by the web container itself using Let's Encrypt, so ports 80/tcp and 443/tcp as well as 10000/udp (media) must be reachable.
+
+Optionally, a [coturn](https://github.com/coturn/coturn) TURN server and the [Prometheus Jitsi Meet exporter](https://github.com/systemli/prometheus-jitsi-meet-exporter) are deployed as additional services of the same Compose project, defined in `/opt/jitsi/compose.override.yaml`.
+coturn uses the certificate obtained by the web container and is restarted weekly to pick up renewals.
+It needs 3478/udp+tcp, 5349/tcp and the relay port range 49152-65535/udp to be reachable.
+The exporter scrapes the JVB statistics and listens on port 9888.
+
+## Supported Distributions
+
+Should work on Debian-based distributions.
+
+## Required Arguments
+
+- `jitsi__version`: The docker-jitsi-meet release to deploy, e.g. `stable-11248`.
+- `jitsi__domain`: The domain Jitsi is served on. Used for `PUBLIC_URL` and the Let's Encrypt certificate.
+- `jitsi__letsencrypt_email`: The e-mail address for the Let's Encrypt account.
+- `jitsi__jicofo_auth_password`: XMPP password for Jicofo. Generate using `openssl rand -hex 16`.
+- `jitsi__jvb_auth_password`: XMPP password for the JVB. Generate using `openssl rand -hex 16`.
+
+## Optional Arguments
+
+- `jitsi__timezone`: The time zone for the containers.
+ Defaults to `Europe/Berlin`.
+- `jitsi__jvb_advertise_ips`: List of IP addresses the JVB should advertise for media (`JVB_ADVERTISE_IPS`).
+ Defaults to `[ ]`.
+- `jitsi__whiteboard`: Whether to deploy the Excalidraw whiteboard backend (`whiteboard.yml`).
+ Defaults to `false`.
+- `jitsi__turn`: Whether to deploy coturn and configure Jitsi to use it.
+ Defaults to `false`.
+- `jitsi__turn_secret`: The shared secret between Prosody and coturn. Required if `jitsi__turn` is `true`.
+ Generate using `openssl rand -hex 32`.
+- `jitsi__prometheus_exporter`: Whether to deploy the Prometheus Jitsi Meet exporter on port 9888.
+ Defaults to `false`.
+- `jitsi__env`: Dictionary of additional variables to put into the `.env` file.
+ See `env.example` of the release and the handbook for available variables.
+ Defaults to `{ }`.
+- `jitsi__custom_config_js`: Content of `custom-config.js`, which gets appended to the generated `config.js`.
+ Defaults to `""` (no file).
+- `jitsi__custom_interface_config_js`: Content of `custom-interface_config.js`, which gets appended to the generated `interface_config.js`.
+ Defaults to `""` (no file).
+
+## Links & Resources
+
+-
+-
+-
diff --git a/roles/jitsi/defaults/main.yaml b/roles/jitsi/defaults/main.yaml
new file mode 100644
index 00000000..9782b636
--- /dev/null
+++ b/roles/jitsi/defaults/main.yaml
@@ -0,0 +1,8 @@
+jitsi__timezone: Europe/Berlin
+jitsi__jvb_advertise_ips: [ ]
+jitsi__whiteboard: false
+jitsi__turn: false
+jitsi__prometheus_exporter: false
+jitsi__env: { }
+jitsi__custom_config_js: ""
+jitsi__custom_interface_config_js: ""
diff --git a/roles/jitsi/handlers/main.yaml b/roles/jitsi/handlers/main.yaml
new file mode 100644
index 00000000..7bde7f02
--- /dev/null
+++ b/roles/jitsi/handlers/main.yaml
@@ -0,0 +1,19 @@
+- name: restart jitsi web
+ community.docker.docker_compose_v2:
+ project_src: "{{ jitsi__base_dir }}"
+ project_name: jitsi
+ files: "{{ jitsi__compose_files }}"
+ services:
+ - web
+ state: restarted
+ become: true
+
+- name: restart jitsi coturn
+ community.docker.docker_compose_v2:
+ project_src: "{{ jitsi__base_dir }}"
+ project_name: jitsi
+ files: "{{ jitsi__compose_files }}"
+ services:
+ - coturn
+ state: restarted
+ become: true
diff --git a/roles/jitsi/meta/argument_specs.yaml b/roles/jitsi/meta/argument_specs.yaml
new file mode 100644
index 00000000..2c65ca54
--- /dev/null
+++ b/roles/jitsi/meta/argument_specs.yaml
@@ -0,0 +1,57 @@
+argument_specs:
+ main:
+ options:
+ jitsi__version:
+ type: str
+ required: true
+ jitsi__domain:
+ type: str
+ required: true
+ jitsi__letsencrypt_email:
+ type: str
+ required: true
+ jitsi__jicofo_auth_password:
+ type: str
+ required: true
+ no_log: true
+ jitsi__jvb_auth_password:
+ type: str
+ required: true
+ no_log: true
+ jitsi__timezone:
+ type: str
+ required: false
+ default: Europe/Berlin
+ jitsi__jvb_advertise_ips:
+ type: list
+ elements: str
+ required: false
+ default: [ ]
+ jitsi__whiteboard:
+ type: bool
+ required: false
+ default: false
+ jitsi__turn:
+ type: bool
+ required: false
+ default: false
+ jitsi__turn_secret:
+ type: str
+ required: false
+ no_log: true
+ jitsi__prometheus_exporter:
+ type: bool
+ required: false
+ default: false
+ jitsi__env:
+ type: dict
+ required: false
+ default: { }
+ jitsi__custom_config_js:
+ type: str
+ required: false
+ default: ""
+ jitsi__custom_interface_config_js:
+ type: str
+ required: false
+ default: ""
diff --git a/roles/jitsi/meta/main.yaml b/roles/jitsi/meta/main.yaml
new file mode 100644
index 00000000..1fbb2364
--- /dev/null
+++ b/roles/jitsi/meta/main.yaml
@@ -0,0 +1,4 @@
+---
+allow_duplicates: false
+dependencies:
+ - role: docker
diff --git a/roles/jitsi/tasks/main.yaml b/roles/jitsi/tasks/main.yaml
new file mode 100644
index 00000000..a7beaaa4
--- /dev/null
+++ b/roles/jitsi/tasks/main.yaml
@@ -0,0 +1,186 @@
+- name: make sure a TURN secret is given, if TURN is enabled
+ ansible.builtin.assert:
+ that:
+ - jitsi__turn_secret is defined
+ - jitsi__turn_secret | length > 0
+ quiet: true
+ when: jitsi__turn
+
+- name: make sure the Jitsi base directories exist
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: directory
+ mode: "0755"
+ owner: root
+ group: root
+ become: true
+ loop:
+ - "{{ jitsi__base_dir }}"
+ - "{{ jitsi__base_dir }}/releases"
+
+- name: make sure the docker-jitsi-meet release is present
+ ansible.builtin.unarchive:
+ src: "https://github.com/jitsi/docker-jitsi-meet/archive/refs/tags/{{ jitsi__version }}.tar.gz"
+ remote_src: true
+ dest: "{{ jitsi__base_dir }}/releases"
+ creates: "{{ jitsi__release_dir }}/docker-compose.yml"
+ owner: root
+ group: root
+ become: true
+
+- name: point the `release` symlink to the configured release
+ ansible.builtin.file:
+ src: "{{ jitsi__release_dir }}"
+ dest: "{{ jitsi__base_dir }}/release"
+ state: link
+ owner: root
+ group: root
+ become: true
+
+# Configuration read by the containers at startup; managed by Ansible.
+- name: make sure the configuration input directories exist
+ ansible.builtin.file:
+ path: "{{ jitsi__config_dir }}/{{ item }}"
+ state: directory
+ mode: "0755"
+ owner: root
+ group: root
+ become: true
+ loop:
+ - ""
+ - web
+ - prosody
+ - prosody/config
+ - prosody/prosody-plugins-custom
+ - jicofo
+ - jvb
+ - storage
+ - tmp
+
+# State written by the containers, which run as uid/gid 1000.
+# The certificate directory is created here, as Docker would otherwise create it owned by
+# root for the coturn bind mount, before the web container gets to write the certificate.
+- name: make sure the storage and tmp directories exist and are writable by the containers
+ ansible.builtin.file:
+ path: "{{ jitsi__config_dir }}/{{ item }}"
+ state: directory
+ mode: "0750"
+ owner: "1000"
+ group: "1000"
+ become: true
+ loop:
+ - storage/prosody
+ - storage/web
+ - storage/web/acme-certs
+ - "storage/web/acme-certs/{{ jitsi__domain }}"
+ - storage/transcripts
+ - tmp/web-crontabs
+ - tmp/web-load-test
+
+- name: deploy custom web configuration
+ ansible.builtin.copy:
+ content: "{{ item.content }}"
+ dest: "{{ jitsi__config_dir }}/web/{{ item.name }}"
+ mode: "0644"
+ owner: root
+ group: root
+ become: true
+ when: item.content != ""
+ loop:
+ - name: custom-config.js
+ content: "{{ jitsi__custom_config_js }}"
+ - name: custom-interface_config.js
+ content: "{{ jitsi__custom_interface_config_js }}"
+ loop_control:
+ label: "{{ item.name }}"
+ notify: restart jitsi web
+
+- name: remove unconfigured custom web configuration
+ ansible.builtin.file:
+ path: "{{ jitsi__config_dir }}/web/{{ item.name }}"
+ state: absent
+ become: true
+ when: item.content == ""
+ loop:
+ - name: custom-config.js
+ content: "{{ jitsi__custom_config_js }}"
+ - name: custom-interface_config.js
+ content: "{{ jitsi__custom_interface_config_js }}"
+ loop_control:
+ label: "{{ item.name }}"
+ notify: restart jitsi web
+
+- name: make sure the coturn directory exists
+ ansible.builtin.file:
+ path: "{{ jitsi__base_dir }}/coturn"
+ state: "{{ 'directory' if jitsi__turn else 'absent' }}"
+ mode: "0755"
+ owner: root
+ group: root
+ become: true
+
+# Readable by uid 1000, which coturn runs as.
+- name: deploy the coturn configuration
+ ansible.builtin.template:
+ src: turnserver.conf.j2
+ dest: "{{ jitsi__base_dir }}/coturn/turnserver.conf"
+ mode: "0640"
+ owner: root
+ group: "1000"
+ become: true
+ when: jitsi__turn
+ notify: restart jitsi coturn
+
+# coturn doesn't notice the certificate renewed by the web container on its own.
+- name: ensure the weekly coturn restart cron job is present
+ ansible.builtin.cron:
+ name: "jitsi coturn restart for certificate renewal"
+ special_time: weekly
+ job: "cd {{ jitsi__base_dir }} && docker compose restart coturn >/dev/null"
+ state: "{{ 'present' if jitsi__turn else 'absent' }}"
+ become: true
+
+# Changes to these make `docker compose up` below recreate the affected containers.
+- name: deploy the compose override file
+ ansible.builtin.template:
+ src: compose.override.yaml.j2
+ dest: "{{ jitsi__base_dir }}/compose.override.yaml"
+ mode: "0644"
+ owner: root
+ group: root
+ become: true
+
+- name: deploy the .env file
+ ansible.builtin.template:
+ src: jitsi.env.j2
+ dest: "{{ jitsi__base_dir }}/.env"
+ mode: "0600"
+ owner: root
+ group: root
+ become: true
+
+- name: docker compose up
+ community.docker.docker_compose_v2:
+ project_src: "{{ jitsi__base_dir }}"
+ project_name: jitsi
+ files: "{{ jitsi__compose_files }}"
+ state: present
+ pull: missing
+ remove_orphans: true
+ become: true
+
+- name: find old releases
+ ansible.builtin.find:
+ paths: "{{ jitsi__base_dir }}/releases"
+ file_type: directory
+ excludes: "docker-jitsi-meet-{{ jitsi__version }}"
+ register: jitsi__old_releases
+
+- name: remove old releases
+ ansible.builtin.file:
+ path: "{{ item.path }}"
+ state: absent
+ become: true
+ loop: "{{ jitsi__old_releases.files }}"
+ loop_control:
+ label: "{{ item.path }}"
diff --git a/roles/jitsi/templates/compose.override.yaml.j2 b/roles/jitsi/templates/compose.override.yaml.j2
new file mode 100644
index 00000000..05842569
--- /dev/null
+++ b/roles/jitsi/templates/compose.override.yaml.j2
@@ -0,0 +1,40 @@
+# {{ ansible_managed }}
+# Services in addition to the ones of the docker-jitsi-meet release.
+
+# Docker Compose explicitly disables IPv6 for networks not enabling it, overriding the
+# daemon's default-network-opts.
+networks:
+ meet.jitsi:
+ enable_ipv6: true
+
+services:
+{% if jitsi__turn %}
+ coturn:
+ image: docker.io/coturn/coturn:4.18.0
+ restart: unless-stopped
+ # Recommended by coturn, as Docker performs badly with large port ranges.
+ network_mode: host
+ # Needs to be able to read the certificate obtained by the web container.
+ user: "1000:1000"
+ command: ["-c", "/etc/coturn/turnserver.conf"]
+ volumes:
+ - {{ jitsi__base_dir }}/coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
+ - {{ jitsi__config_dir }}/storage/web/acme-certs/{{ jitsi__domain }}:/certs:ro
+ tmpfs:
+ - /var/lib/coturn
+{% endif %}
+{% if jitsi__prometheus_exporter %}
+ prometheus-jitsi-meet-exporter:
+ image: docker.io/systemli/prometheus-jitsi-meet-exporter:1.4.2
+ restart: unless-stopped
+ command: ["-videobridge-url", "http://jvb:8080/colibri/stats"]
+ ports:
+ - "9888:9888"
+ depends_on:
+ - jvb
+ networks:
+ meet.jitsi:
+{% endif %}
+{% if not (jitsi__turn or jitsi__prometheus_exporter) %}
+ {}
+{% endif %}
diff --git a/roles/jitsi/templates/jitsi.env.j2 b/roles/jitsi/templates/jitsi.env.j2
new file mode 100644
index 00000000..f09b0414
--- /dev/null
+++ b/roles/jitsi/templates/jitsi.env.j2
@@ -0,0 +1,46 @@
+# {{ ansible_managed }}
+# Only the settings differing from the defaults are set here.
+# Reference: {{ jitsi__base_dir }}/release/env.example and
+# https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker
+
+# Allow running `docker compose` by hand in {{ jitsi__base_dir }}.
+COMPOSE_PROJECT_NAME=jitsi
+COMPOSE_FILE={{ jitsi__compose_files | join(':') }}
+
+CONFIG={{ jitsi__config_dir }}
+JITSI_IMAGE_VERSION={{ jitsi__version }}
+TZ={{ jitsi__timezone }}
+PUBLIC_URL=https://{{ jitsi__domain }}
+
+HTTP_PORT=80
+HTTPS_PORT=443
+ENABLE_HTTP_REDIRECT=1
+ENABLE_LETSENCRYPT=1
+LETSENCRYPT_DOMAIN={{ jitsi__domain }}
+LETSENCRYPT_EMAIL={{ jitsi__letsencrypt_email }}
+LETSENCRYPT_ACME_SERVER=letsencrypt
+{% if jitsi__jvb_advertise_ips | length > 0 %}
+JVB_ADVERTISE_IPS={{ jitsi__jvb_advertise_ips | join(',') }}
+{% endif %}
+{% if jitsi__whiteboard %}
+
+WHITEBOARD_COLLAB_SERVER_URL_BASE=http://whiteboard.meet.jitsi
+{% endif %}
+{% if jitsi__turn %}
+
+TURN_CREDENTIALS={{ jitsi__turn_secret }}
+TURN_HOST={{ jitsi__domain }}
+TURN_PORT=3478
+TURN_TRANSPORT=udp,tcp
+TURNS_HOST={{ jitsi__domain }}
+TURNS_PORT=5349
+{% endif %}
+{% if jitsi__env | length > 0 %}
+
+{% for key, value in jitsi__env.items() %}
+{{ key }}={{ value }}
+{% endfor %}
+{% endif %}
+
+JICOFO_AUTH_PASSWORD={{ jitsi__jicofo_auth_password }}
+JVB_AUTH_PASSWORD={{ jitsi__jvb_auth_password }}
diff --git a/roles/jitsi/templates/turnserver.conf.j2 b/roles/jitsi/templates/turnserver.conf.j2
new file mode 100644
index 00000000..28770f81
--- /dev/null
+++ b/roles/jitsi/templates/turnserver.conf.j2
@@ -0,0 +1,34 @@
+# {{ ansible_managed }}
+listening-port=3478
+tls-listening-port=5349
+realm={{ jitsi__domain }}
+use-auth-secret
+static-auth-secret={{ jitsi__turn_secret }}
+cert=/certs/fullchain.pem
+pkey=/certs/key.pem
+no-tlsv1
+no-tlsv1_1
+no-cli
+no-multicast-peers
+no-loopback-peers
+log-file=stdout
+# Don't relay to private and otherwise special addresses.
+denied-peer-ip=0.0.0.0-0.255.255.255
+denied-peer-ip=10.0.0.0-10.255.255.255
+denied-peer-ip=100.64.0.0-100.127.255.255
+denied-peer-ip=127.0.0.0-127.255.255.255
+denied-peer-ip=169.254.0.0-169.254.255.255
+denied-peer-ip=172.16.0.0-172.31.255.255
+denied-peer-ip=192.0.0.0-192.0.0.255
+denied-peer-ip=192.0.2.0-192.0.2.255
+denied-peer-ip=192.88.99.0-192.88.99.255
+denied-peer-ip=192.168.0.0-192.168.255.255
+denied-peer-ip=198.18.0.0-198.19.255.255
+denied-peer-ip=198.51.100.0-198.51.100.255
+denied-peer-ip=203.0.113.0-203.0.113.255
+denied-peer-ip=240.0.0.0-255.255.255.255
+denied-peer-ip=::1
+denied-peer-ip=64:ff9b::-64:ff9b::ffff:ffff
+denied-peer-ip=::ffff:0.0.0.0-::ffff:255.255.255.255
+denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
+denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
diff --git a/roles/jitsi/vars/main.yaml b/roles/jitsi/vars/main.yaml
new file mode 100644
index 00000000..21fe76b6
--- /dev/null
+++ b/roles/jitsi/vars/main.yaml
@@ -0,0 +1,4 @@
+jitsi__base_dir: /opt/jitsi
+jitsi__config_dir: "{{ jitsi__base_dir }}/config"
+jitsi__release_dir: "{{ jitsi__base_dir }}/releases/docker-jitsi-meet-{{ jitsi__version }}"
+jitsi__compose_files: "{{ ['release/docker-compose.yml'] + (['release/whiteboard.yml'] if jitsi__whiteboard else []) + ['compose.override.yaml'] }}"