From 27255a4bfa92bd852d078001379dacd4e78a48b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Fri, 22 May 2026 01:10:56 +0200 Subject: [PATCH] feat(talk/turn/signaling/hpb): add role for Talk with backend services --- roles/coturn/README.md | 69 ++++++++++ roles/coturn/defaults/main.yml | 77 +++++++++++ roles/coturn/handlers/main.yml | 10 ++ roles/coturn/meta/main.yml | 15 +++ roles/coturn/tasks/main.yml | 110 ++++++++++++++++ roles/coturn/templates/docker-compose.yml.j2 | 78 +++++++++++ roles/coturn/tests/inventory | 2 + roles/coturn/tests/tests.yml | 6 + roles/coturn/vars/main.yml | 3 + roles/nextcloud/defaults/main.yml | 20 +++ roles/nextcloud/tasks/main.yml | 4 + roles/nextcloud/tasks/talk.yml | 70 ++++++++++ roles/talk/README.md | 78 +++++++++++ roles/talk/defaults/main.yml | 74 +++++++++++ roles/talk/handlers/main.yml | 8 ++ roles/talk/meta/main.yml | 15 +++ roles/talk/tasks/main.yml | 85 ++++++++++++ roles/talk/templates/docker-compose.yml.j2 | 124 ++++++++++++++++++ roles/talk/templates/janus.jcfg.j2 | 28 ++++ roles/talk/templates/janus.logger.jcfg.j2 | 3 + .../janus.transport.websockets.jcfg.j2 | 7 + roles/talk/templates/server.conf.j2 | 33 +++++ roles/talk/tests/inventory | 2 + roles/talk/tests/test.yml | 6 + roles/talk/vars/main.yml | 3 + 25 files changed, 930 insertions(+) create mode 100644 roles/coturn/README.md create mode 100644 roles/coturn/defaults/main.yml create mode 100644 roles/coturn/handlers/main.yml create mode 100644 roles/coturn/meta/main.yml create mode 100644 roles/coturn/tasks/main.yml create mode 100644 roles/coturn/templates/docker-compose.yml.j2 create mode 100644 roles/coturn/tests/inventory create mode 100644 roles/coturn/tests/tests.yml create mode 100644 roles/coturn/vars/main.yml create mode 100644 roles/nextcloud/tasks/talk.yml create mode 100644 roles/talk/README.md create mode 100644 roles/talk/defaults/main.yml create mode 100644 roles/talk/handlers/main.yml create mode 100644 roles/talk/meta/main.yml create mode 100644 roles/talk/tasks/main.yml create mode 100644 roles/talk/templates/docker-compose.yml.j2 create mode 100644 roles/talk/templates/janus.jcfg.j2 create mode 100644 roles/talk/templates/janus.logger.jcfg.j2 create mode 100644 roles/talk/templates/janus.transport.websockets.jcfg.j2 create mode 100644 roles/talk/templates/server.conf.j2 create mode 100644 roles/talk/tests/inventory create mode 100644 roles/talk/tests/test.yml create mode 100644 roles/talk/vars/main.yml diff --git a/roles/coturn/README.md b/roles/coturn/README.md new file mode 100644 index 0000000..13d1c3e --- /dev/null +++ b/roles/coturn/README.md @@ -0,0 +1,69 @@ +# coturn + +Deploys a [coturn](https://github.com/coturn/coturn) TURN/STUN server with `network_mode: host`, +optionally accompanied by an `acme.sh` sidecar that obtains and renews a public TLS certificate +via RFC2136 (`nsupdate`) and restarts coturn on renewal. + +This is the recommended pairing for `digitalboard.core.talk` (Nextcloud Talk HPB). + +## What it does + +- Renders `/etc/docker/compose/coturn/docker-compose.yml` +- (acme mode) Deploys the TSIG key from `playbooks/secrets/{{ inventory_hostname }}/nsupdate.key` +- (selfsigned mode) Generates an ECC keypair + selfsigned cert in `{{ coturn_cert_dir }}` +- Starts the stack via `community.docker.docker_compose_v2` + +## Required variables + +| Variable | Description | +|---|---| +| `coturn_realm` | Public DNS name used as realm + cert CN (e.g. `stun.digitalboard.ch`) | +| `coturn_external_ip` | Mapping for `--external-ip`, format `PUBLIC[/PRIVATE]` | +| `coturn_static_auth_secret` | Shared secret for HMAC-based credentials; **must match** `talk_turn_secret` on the HPB host | + +## Important variables + +| Variable | Default | Description | +|---|---|---| +| `coturn_cert_mode` | `file` | One of `acme`, `file`, `selfsigned` | +| `coturn_listening_port` | `443` | TCP/UDP non-TLS port | +| `coturn_tls_listening_port` | `443` | TLS port (shared with non-TLS via STUN mux) | +| `coturn_min_relay_port` / `coturn_max_relay_port` | `49160` / `49200` | UDP relay range | +| `coturn_internal_realm` | `""` | Optional second SAN for split-horizon DNS | +| `coturn_image` | `coturn/coturn:4.6.2-r5-alpine` | Pinned by default; override as needed | + +## ACME / nsupdate mode + +When `coturn_cert_mode: acme` is set, also configure: + +```yaml +coturn_acme_email: "admin@digitalboard.ch" +coturn_acme_nsupdate_server: "ns1.digitalboard.ch" +coturn_acme_nsupdate_server_ip: "172.16.9.169" # optional pin +coturn_acme_nsupdate_zone: "digitalboard._acme.digitalboard.ch" +# optional: override the auto-built challenge alias mapping +coturn_acme_challenge_aliases: + - name: stun.digitalboard.ch + alias: stun.digitalboard._acme.digitalboard.ch + - name: stun.int.digitalboard.ch + alias: stun.int.digitalboard._acme.digitalboard.ch +``` + +Place your TSIG key at `playbooks/secrets/{{ inventory_hostname }}/nsupdate.key` (mode 0600). + +## Secrets + +Place the static auth secret at: + +``` +playbooks/secrets/{{ inventory_hostname }}/coturn_static_auth_secret +``` + +Mode 0600. The same value must be deployed to the HPB host as `talk_turn_secret`. + +## Firewall + +The role does not manage firewall rules. Ensure the host has: + +- `443/tcp` and `443/udp` reachable from the internet +- UDP `{{ coturn_min_relay_port }}-{{ coturn_max_relay_port }}` reachable from the internet diff --git a/roles/coturn/defaults/main.yml b/roles/coturn/defaults/main.yml new file mode 100644 index 0000000..580d9da --- /dev/null +++ b/roles/coturn/defaults/main.yml @@ -0,0 +1,77 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for coturn + +# Base directories (inherited from base role) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# Service-specific paths +coturn_service_name: coturn +coturn_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ coturn_service_name }}" +coturn_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ coturn_service_name }}" + +# Container images (pin per host_vars in production) +coturn_image: "coturn/coturn:4.6.2-r5-alpine" +coturn_acme_image: "neilpang/acme.sh:3.1.0" + +# Public DNS name used for the realm and the public certificate +coturn_realm: "stun.example.test" +# Optional second DNS name issued on the same certificate (for split-horizon "internal" name) +coturn_internal_realm: "" # e.g. "stun.int.example.test" + +# Ports +# Defaults follow IANA standards (3478/TURN, 5349/TURNS) so coturn can +# co-exist with a Traefik instance on the same host. Override to 443/443 +# in restrictive-network environments where punching through firewalls matters. +coturn_listening_port: 3478 # TURN / STUN (TCP+UDP) +coturn_tls_listening_port: 5349 # TURNS (TCP+UDP) +coturn_min_relay_port: 49160 +coturn_max_relay_port: 49200 + +# IP advertisement: must be set in host_vars for production +# Format follows coturn's --external-ip: "PUBLIC_IP" or "PUBLIC_IP/PRIVATE_IP" +coturn_external_ip: "" # e.g. "203.0.113.10/172.18.0.2" +coturn_listening_ip: "0.0.0.0" + +# Shared secret used by HPB to mint short-lived TURN credentials. +# Loaded by default from a plain file in playbooks/secrets/{host}/coturn_static_auth_secret +# Override per host_vars if you want to use a vault or different lookup. +coturn_static_auth_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/coturn_static_auth_secret') }}" + +# Additional CLI flags (list of strings, appended verbatim to command:) +coturn_extra_args: [] + +# --- TLS certificate --- +# 'acme' : run an acme.sh sidecar that issues + renews via RFC2136 / nsupdate, restarts coturn +# 'file' : assume a certificate already lives at {{ coturn_cert_dir }}/{{ coturn_cert_file }} on the host (you manage it) +# 'selfsigned' : generate a selfsigned cert on first run (for vagrant/dev only) +coturn_cert_mode: "file" + +coturn_cert_dir: "{{ docker_volume_base_dir }}/acme/certs" +coturn_cert_file: "fullchain.cer" +coturn_key_file: "{{ coturn_realm }}.key" + +# --- acme.sh sidecar (only used when coturn_cert_mode == 'acme') --- +coturn_acme_email: "admin@example.test" +coturn_acme_directory: "https://acme-v02.api.letsencrypt.org/directory" +# Stage URL for testing: "https://acme-staging-v02.api.letsencrypt.org/directory" +coturn_acme_keylength: "ec-256" +coturn_acme_dnssleep: 60 +coturn_acme_data_dir: "{{ docker_volume_base_dir }}/acme/acme" + +# DNS-01 RFC2136 / nsupdate configuration +coturn_acme_nsupdate_server: "" # e.g. "ns1.example.test" +coturn_acme_nsupdate_server_ip: "" # optional extra_hosts pin (string IP) for the server +coturn_acme_nsupdate_zone: "" # e.g. "example._acme.example.test" +# Per-name challenge alias zones (one entry per SAN) +# When empty (default), built automatically as "{{ realm }}._acme.{{ zone-tail }}" +coturn_acme_challenge_aliases: [] +# Example: +# - name: stun.example.test +# alias: stun.example._acme.example.test +# - name: stun.int.example.test +# alias: stun.int.example._acme.example.test + +# Path of the TSIG key file inside the container (mounted from secrets) +coturn_acme_nsupdate_key_src: "{{ playbook_dir }}/secrets/{{ inventory_hostname }}/nsupdate.key" diff --git a/roles/coturn/handlers/main.yml b/roles/coturn/handlers/main.yml new file mode 100644 index 0000000..0abd12f --- /dev/null +++ b/roles/coturn/handlers/main.yml @@ -0,0 +1,10 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for coturn + +- name: Restart coturn container + community.docker.docker_compose_v2: + project_src: "{{ coturn_docker_compose_dir }}" + state: restarted + services: + - coturn diff --git a/roles/coturn/meta/main.yml b/roles/coturn/meta/main.yml new file mode 100644 index 0000000..68d93a9 --- /dev/null +++ b/roles/coturn/meta/main.yml @@ -0,0 +1,15 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: Digital Board Team + description: Deploy a coturn TURN/STUN server with optional acme.sh sidecar (RFC2136/nsupdate) + company: digitalboard.ch + license: GPL-2.0-or-later + min_ansible_version: "2.14" + galaxy_tags: + - turn + - stun + - coturn + - webrtc + - nextcloud + - talk +dependencies: [] diff --git a/roles/coturn/tasks/main.yml b/roles/coturn/tasks/main.yml new file mode 100644 index 0000000..cf9c15a --- /dev/null +++ b/roles/coturn/tasks/main.yml @@ -0,0 +1,110 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for coturn + +- name: Assert minimum configuration + ansible.builtin.assert: + that: + - coturn_realm | length > 0 + - coturn_external_ip | length > 0 + - coturn_static_auth_secret | length > 0 + fail_msg: > + coturn_realm, coturn_external_ip and coturn_static_auth_secret must be set. + Provide them in host_vars or via a secrets file. + +- name: Create coturn compose directory + ansible.builtin.file: + path: "{{ coturn_docker_compose_dir }}" + state: directory + mode: "0755" + +- name: Create coturn data directory + ansible.builtin.file: + path: "{{ coturn_docker_volume_dir }}" + state: directory + mode: "0755" + +- name: Create certificate directory + ansible.builtin.file: + path: "{{ coturn_cert_dir }}" + state: directory + mode: "0755" + +# --- TLS certificate provisioning ------------------------------------------------- + +- name: Configure acme.sh sidecar (TSIG key + acme data dir) + when: coturn_cert_mode == 'acme' + block: + - name: Create acme.sh data directory + ansible.builtin.file: + path: "{{ coturn_acme_data_dir }}" + state: directory + mode: "0700" + + - name: Deploy nsupdate TSIG key + ansible.builtin.copy: + src: "{{ coturn_acme_nsupdate_key_src }}" + dest: "{{ coturn_docker_compose_dir }}/nsupdate.key" + mode: "0600" + no_log: true + notify: Restart coturn container + + - name: Build effective challenge alias list (default if not provided) + ansible.builtin.set_fact: + _coturn_challenge_aliases: >- + {{ coturn_acme_challenge_aliases + if coturn_acme_challenge_aliases | length > 0 + else ( + [{'name': coturn_realm, + 'alias': (coturn_realm.split('.')[:-2] | join('.')) ~ '.' ~ coturn_acme_nsupdate_zone }] + + ([{'name': coturn_internal_realm, + 'alias': (coturn_internal_realm.split('.')[:-2] | join('.')) ~ '.' ~ coturn_acme_nsupdate_zone }] + if coturn_internal_realm | length > 0 else []) + ) + }} + +- name: Generate selfsigned certificate (vagrant / dev only) + when: coturn_cert_mode == 'selfsigned' + block: + - name: Ensure openssl is available + ansible.builtin.package: + name: openssl + state: present + + - name: Generate selfsigned private key + community.crypto.openssl_privatekey: + path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}" + type: ECC + curve: secp256r1 + mode: "0600" + + - name: Generate selfsigned CSR + community.crypto.openssl_csr: + path: "{{ coturn_cert_dir }}/{{ coturn_realm }}.csr" + privatekey_path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}" + common_name: "{{ coturn_realm }}" + subject_alt_name: + - "DNS:{{ coturn_realm }}" + mode: "0644" + + - name: Issue selfsigned certificate + community.crypto.x509_certificate: + path: "{{ coturn_cert_dir }}/{{ coturn_cert_file }}" + privatekey_path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}" + csr_path: "{{ coturn_cert_dir }}/{{ coturn_realm }}.csr" + provider: selfsigned + mode: "0644" + +# --- Compose + start -------------------------------------------------------------- + +- name: Generate docker-compose.yml for coturn + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ coturn_docker_compose_dir }}/docker-compose.yml" + mode: "0644" + notify: Restart coturn container + +- name: Start coturn stack + community.docker.docker_compose_v2: + project_src: "{{ coturn_docker_compose_dir }}" + state: present diff --git a/roles/coturn/templates/docker-compose.yml.j2 b/roles/coturn/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..42bdcb5 --- /dev/null +++ b/roles/coturn/templates/docker-compose.yml.j2 @@ -0,0 +1,78 @@ +services: + coturn: + image: {{ coturn_image }} + container_name: {{ coturn_service_name }} + restart: always + network_mode: host + volumes: + - {{ coturn_cert_dir }}:/certs:ro + command: + - --use-auth-secret + - --static-auth-secret={{ coturn_static_auth_secret }} + - --realm={{ coturn_realm }} + - --fingerprint + - --no-multicast-peers + - --no-cli + - --listening-ip={{ coturn_listening_ip }} + - --listening-port={{ coturn_listening_port }} + - --tls-listening-port={{ coturn_tls_listening_port }} + - --min-port={{ coturn_min_relay_port }} + - --max-port={{ coturn_max_relay_port }} + - --cert=/certs/{{ coturn_cert_file }} + - --pkey=/certs/{{ coturn_key_file }} + - --external-ip={{ coturn_external_ip }} +{% for arg in coturn_extra_args %} + - {{ arg }} +{% endfor %} + +{% if coturn_cert_mode == 'acme' %} + acme: + image: {{ coturn_acme_image }} + container_name: acme-{{ coturn_service_name }} + restart: always + environment: + NSUPDATE_SERVER: "{{ coturn_acme_nsupdate_server }}" + NSUPDATE_KEY: "/acme.sh/nsupdate.key" + ACME_DIRECTORY: "{{ coturn_acme_directory }}" + NSUPDATE_ZONE: "{{ coturn_acme_nsupdate_zone }}" +{% if coturn_acme_nsupdate_server_ip | length > 0 %} + extra_hosts: + - "{{ coturn_acme_nsupdate_server }}:{{ coturn_acme_nsupdate_server_ip }}" +{% endif %} + volumes: + - {{ coturn_cert_dir }}:/certs + - /var/run/docker.sock:/var/run/docker.sock + - {{ coturn_docker_compose_dir }}/nsupdate.key:/acme.sh/nsupdate.key:ro + - {{ coturn_acme_data_dir }}:/acme.sh + entrypoint: + - /bin/sh + - -c + - | + set -eu + acme.sh --set-default-ca --server "$$ACME_DIRECTORY" + acme.sh --register-account -m {{ coturn_acme_email }} || true + set +e + acme.sh --issue \ +{% for san in _coturn_challenge_aliases %} + -d {{ san.name }} \ + --challenge-alias {{ san.alias }} \ +{% endfor %} + --dns dns_nsupdate \ + --keylength {{ coturn_acme_keylength }} \ + --dnssleep {{ coturn_acme_dnssleep }} + rc=$$? + set -e + if [ "$$rc" -eq 0 ]; then + echo "Issue: success" + elif [ "$$rc" -eq 2 ]; then + echo "Issue: not due, continuing" + else + echo "Issue: failed with rc=$$rc" + exit "$$rc" + fi + acme.sh --install-cert -d {{ coturn_realm }} --ecc \ + --fullchain-file /certs/{{ coturn_cert_file }} \ + --key-file /certs/{{ coturn_key_file }} \ + --reloadcmd 'curl --fail --silent --show-error --unix-socket /var/run/docker.sock -X POST http://localhost/v1.41/containers/{{ coturn_service_name }}/restart' || true + exec crond -f +{% endif %} diff --git a/roles/coturn/tests/inventory b/roles/coturn/tests/inventory new file mode 100644 index 0000000..eec845d --- /dev/null +++ b/roles/coturn/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost \ No newline at end of file diff --git a/roles/coturn/tests/tests.yml b/roles/coturn/tests/tests.yml new file mode 100644 index 0000000..828e0fb --- /dev/null +++ b/roles/coturn/tests/tests.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - coturn diff --git a/roles/coturn/vars/main.yml b/roles/coturn/vars/main.yml new file mode 100644 index 0000000..fedd529 --- /dev/null +++ b/roles/coturn/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for httpbin diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index 7535b5a..0c96046 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -61,6 +61,26 @@ nextcloud_trusted_proxies: "172.16.0.0/12" nextcloud_enable_notify_push: false nextcloud_notify_push_image: "icewind1991/notify_push:1.3.1" +# Nextcloud Talk: register external HPB signaling + TURN + STUN +# Set to true to run tasks/talk.yml after Nextcloud is up. +nextcloud_enable_talk: false + +# HPB signaling servers to register. +# Each item: { server: "https://signaling.example.test", secret: "", verify: true } +nextcloud_talk_signaling_servers: [] +# Hostnames/URLs to remove via `talk:signaling:delete` before adding the new set. +nextcloud_talk_signaling_servers_removed: [] + +# TURN servers to register. +# Each item: { server: "stun.example.test:443", secret: "", schemes: "turn,turns", protocols: "udp,tcp" } +nextcloud_talk_turn_servers: [] +# Clear the spreed.turn_servers config key before re-adding (single source of truth) +nextcloud_talk_turn_reset_before_add: true + +# Plain STUN endpoints (host:port). Optional; usually not needed if TURN servers handle STUN too. +nextcloud_talk_stun_servers: [] +nextcloud_talk_stun_servers_removed: [] + # Non-default apps to install and enable nextcloud_apps_to_install: - groupfolders diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index 8d2a5cd..e33088b 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -91,3 +91,7 @@ - name: Configure OIDC providers ansible.builtin.include_tasks: oidc.yml when: nextcloud_oidc_providers | length > 0 or nextcloud_oidc_providers_removed | length > 0 + +- name: Configure Nextcloud Talk (HPB + TURN + STUN) + ansible.builtin.include_tasks: talk.yml + when: nextcloud_enable_talk diff --git a/roles/nextcloud/tasks/talk.yml b/roles/nextcloud/tasks/talk.yml new file mode 100644 index 0000000..aaf67e3 --- /dev/null +++ b/roles/nextcloud/tasks/talk.yml @@ -0,0 +1,70 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for configuring Nextcloud Talk HPB + TURN + STUN registration + +# --- HPB / signaling ----------------------------------------------------------- + +- name: Remove HPB signaling servers no longer in use + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ talk:signaling:delete {{ item }} + loop: "{{ nextcloud_talk_signaling_servers_removed }}" + register: _talk_sig_removed + changed_when: "'deleted' in (_talk_sig_removed.stdout | default(''))" + failed_when: + - _talk_sig_removed.rc != 0 + - "'is not configured' not in (_talk_sig_removed.stderr | default(''))" + +- name: Register HPB signaling servers + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: > + php /var/www/html/occ talk:signaling:add + {{ item.server }} + {{ item.secret }} + {% if item.verify | default(true) %}--verify{% endif %} + loop: "{{ nextcloud_talk_signaling_servers }}" + no_log: true + +# --- TURN ---------------------------------------------------------------------- +# `talk:turn:add` appends without deduplication, so on each run we first clear +# the list via the underlying app config key (turn_servers, JSON array) and +# then re-add the declared set. This keeps the host_vars list as the single +# source of truth. + +- name: Reset TURN server list before re-applying + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ config:app:set spreed turn_servers --value='[]' + when: nextcloud_talk_turn_reset_before_add | bool + +- name: Register TURN servers + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: > + php /var/www/html/occ talk:turn:add + {{ item.schemes | default('turn,turns') }} + {{ item.server }} + {{ item.protocols | default('udp,tcp') }} + --secret={{ item.secret }} + loop: "{{ nextcloud_talk_turn_servers }}" + no_log: true + +# --- STUN ---------------------------------------------------------------------- + +- name: Remove STUN servers no longer in use + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ talk:stun:delete {{ item }} + loop: "{{ nextcloud_talk_stun_servers_removed }}" + register: _talk_stun_removed + changed_when: "'deleted' in (_talk_stun_removed.stdout | default(''))" + failed_when: + - _talk_stun_removed.rc != 0 + - "'is not configured' not in (_talk_stun_removed.stderr | default(''))" + +- name: Register STUN servers + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ talk:stun:add {{ item }} + loop: "{{ nextcloud_talk_stun_servers }}" diff --git a/roles/talk/README.md b/roles/talk/README.md new file mode 100644 index 0000000..28652be --- /dev/null +++ b/roles/talk/README.md @@ -0,0 +1,78 @@ +# talk + +Deploys the Nextcloud Talk High Performance Backend (HPB) stack: + +- `nextcloud-spreed-signaling` (Strukturag) +- `janus-gateway` (canyan build, WebRTC MCU) +- `nats` (internal message broker) + +Designed to be paired with the `digitalboard.core.coturn` role (TURN/STUN) and registered in +Nextcloud via the new `digitalboard.core.nextcloud` `talk.yml` task. + +## Required variables + +| Variable | Description | +|---|---| +| `talk_domain` | Public host name (e.g. `signaling.digitalboard.ch`) | +| `talk_nextcloud_url` | Base URL of the Nextcloud instance the HPB talks back to | +| `talk_janus_public_ip` | Public IP used by Janus for ICE candidate gathering (nat_1_1_mapping) | +| `talk_backend_secret` | HMAC secret shared with Nextcloud Talk; loaded from `secrets/{host}/talk_backend_secret` | +| `talk_turn_secret` | Shared secret with coturn; loaded from `secrets/{host}/talk_turn_secret` (must equal `coturn_static_auth_secret`) | +| `talk_session_hashkey` | 32-byte hex; loaded from `secrets/{host}/talk_session_hashkey` | +| `talk_session_blockkey` | 32-byte hex; loaded from `secrets/{host}/talk_session_blockkey` | + +## Important variables + +| Variable | Default | Description | +|---|---|---| +| `talk_internal_domain` | `""` | Optional split-horizon FQDN (matches the second SAN on the coturn cert) | +| `talk_turn_servers` | `turns:.../443?transport=tcp,turn:.../443` | Comma-separated TURN URI list passed to the signaling server | +| `talk_turn_realm` | `stun.example.test` | Realm advertised to clients | +| `talk_janus_stun_server` | `stun.int.example.test` | STUN endpoint Janus uses for its own ICE; default points at the internal coturn name | +| `talk_janus_rtp_port_min/max` | `20000`/`21000` | UDP/TCP relay range opened on the Janus container | +| `talk_nextcloud_extra_host_ip` | `""` | Optional pin: bind the Nextcloud FQDN to a specific backend IP (bypasses hairpin/SNI) | +| `talk_signaling_image` | `strukturag/nextcloud-spreed-signaling:1.3.4` | Pinned | +| `talk_janus_image` | `canyan/janus-gateway:1.2.4` | Pinned | +| `talk_nats_image` | `nats:2.10-alpine` | Pinned | + +All defaults can be overridden per host_vars. The configurable image variables exist explicitly because +this stack is still under active development upstream and you may want to roll forward independently. + +## Secrets + +The role expects these files under `playbooks/secrets/{{ inventory_hostname }}/`, mode 0600: + +``` +talk_backend_secret # shared with Nextcloud Talk app (HPB shared secret) +talk_turn_secret # = coturn_static_auth_secret on the TURN host +talk_session_hashkey # 32-byte hex (openssl rand -hex 32) +talk_session_blockkey # 32-byte hex (openssl rand -hex 32) +``` + +If you prefer a different secret store, override the variables directly in host_vars. + +## What gets registered in Nextcloud + +The matching `digitalboard.core.nextcloud` task `talk.yml` runs: + +- `php occ talk:signaling:add ` — register HPB +- `php occ talk:turn:add` for each entry in `nextcloud_talk_turn_servers` — register TURN + +That part lives in the **nextcloud** role and runs when `nextcloud_enable_talk: true`. + +## Traefik + +The role assumes a `digitalboard.core.traefik` instance in `backend` mode runs on the same host +(picks up Docker container labels). The public `talk_domain` then needs to be exposed via the +**DMZ Traefik**, by adding an entry to `traefik_dmz_exposed_services` in the signaling host's +`host_vars`: + +```yaml +traefik_dmz_exposed_services: + - name: signaling + domain: signaling.digitalboard.ch + port: 443 + protocol: https +``` + +(The DMZ proxy aggregates exposed services from all `backend_servers` host_vars.) diff --git a/roles/talk/defaults/main.yml b/roles/talk/defaults/main.yml new file mode 100644 index 0000000..79a3a00 --- /dev/null +++ b/roles/talk/defaults/main.yml @@ -0,0 +1,74 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for talk (Nextcloud Talk High Performance Backend) + +# Base directories (inherited from base role) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +talk_service_name: signaling +talk_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ talk_service_name }}" +talk_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ talk_service_name }}" + +# --- Container images (pinned) --- +talk_signaling_image: "strukturag/nextcloud-spreed-signaling:1.3.4" +talk_janus_image: "canyan/janus-gateway:1.2.4" +talk_nats_image: "nats:2.10-alpine" + +# --- Networking --- +talk_traefik_network: "proxy" +talk_internal_network: "hpb_internal" + +# --- Public exposure --- +talk_use_ssl: true +talk_cert_resolver: "dns" +talk_domain: "signaling.example.test" # public domain (over DMZ Traefik) +talk_internal_domain: "" # optional split-horizon "int" domain (e.g. signaling.int.example.test) + +# --- Backend (Nextcloud) registration --- +# Nextcloud base URL the HPB talks back to. Must be reachable from the HPB container. +talk_nextcloud_url: "https://cloud.example.test" +# Pin Nextcloud domain to a backend IP via extra_hosts to bypass DMZ hairpin/SNI issues +talk_nextcloud_extra_host_ip: "" # e.g. "172.16.9.88" — empty disables the pin + +# Backend HMAC secret shared with Nextcloud Talk. +# Pattern follows playbooks/secrets/{host}/; override the lookup with vault if desired. +talk_backend_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_backend_secret') }}" + +# --- TURN integration --- +# Shared secret with coturn (--static-auth-secret). Must match coturn_static_auth_secret on the TURN host. +talk_turn_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_turn_secret') }}" +# TURN server URI list as understood by the signaling server. +# Defaults follow IANA standards (3478/5349). Override to ":443" in restrictive +# network environments where coturn binds on 443. +talk_turn_servers: "turns:stun.example.test:5349?transport=tcp,turn:stun.example.test:3478" +talk_turn_realm: "stun.example.test" +talk_turn_apikey: "" # optional; if empty a random one is generated on first run + +# --- Session keys (server.conf [sessions]) --- +# 32-byte hex strings. Loaded from secrets dir like the other shared secrets. +talk_session_hashkey: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_session_hashkey') }}" +talk_session_blockkey: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_session_blockkey') }}" + +# --- MCU (Janus) --- +talk_mcu_type: "janus" +talk_janus_public_ip: "" # set in host_vars; goes into janus nat_1_1_mapping +talk_janus_rtp_port_min: 20000 +talk_janus_rtp_port_max: 21000 +# STUN server Janus uses for its own ICE candidate gathering. Default points to internal coturn DNS name. +talk_janus_stun_server: "stun.int.example.test" +talk_janus_stun_port: 5349 +talk_janus_ice_lite: true +talk_janus_ice_tcp: true + +# --- Trusted proxies / allowed hosts for the signaling [app] section --- +talk_trusted_proxies: + - "172.16.0.0/12" + - "192.168.0.0/16" + - "10.0.0.0/8" +talk_allowed_hosts: + - "172.16.0.0/12" + +# --- Extra hosts forwarded to all three containers --- +# Pre-populated with the Nextcloud pin if talk_nextcloud_extra_host_ip is set; you can append more here. +talk_extra_hosts: [] diff --git a/roles/talk/handlers/main.yml b/roles/talk/handlers/main.yml new file mode 100644 index 0000000..645244d --- /dev/null +++ b/roles/talk/handlers/main.yml @@ -0,0 +1,8 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for talk + +- name: Restart signaling stack + community.docker.docker_compose_v2: + project_src: "{{ talk_docker_compose_dir }}" + state: restarted diff --git a/roles/talk/meta/main.yml b/roles/talk/meta/main.yml new file mode 100644 index 0000000..7857f43 --- /dev/null +++ b/roles/talk/meta/main.yml @@ -0,0 +1,15 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: Digital Board Team + description: Deploy Nextcloud Talk High Performance Backend (nextcloud-spreed-signaling + Janus + NATS) + company: digitalboard.ch + license: GPL-2.0-or-later + min_ansible_version: "2.14" + galaxy_tags: + - nextcloud + - talk + - signaling + - hpb + - janus + - webrtc +dependencies: [] diff --git a/roles/talk/tasks/main.yml b/roles/talk/tasks/main.yml new file mode 100644 index 0000000..3a984cf --- /dev/null +++ b/roles/talk/tasks/main.yml @@ -0,0 +1,85 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for talk (HPB) + +- name: Assert minimum configuration + ansible.builtin.assert: + that: + - talk_domain | length > 0 + - talk_nextcloud_url | length > 0 + - talk_backend_secret | length > 0 + - talk_turn_secret | length > 0 + - talk_janus_public_ip | length > 0 + - talk_session_hashkey | length > 0 + - talk_session_blockkey | length > 0 + fail_msg: > + Required talk_* variables missing. + Set talk_domain, talk_nextcloud_url, talk_janus_public_ip in host_vars + and place backend/turn/session secrets in playbooks/secrets/{{ inventory_hostname }}/. + +- name: Create talk compose directory + ansible.builtin.file: + path: "{{ talk_docker_compose_dir }}" + state: directory + mode: "0755" + +- name: Create signaling subdirectories (signaling + janus configs) + ansible.builtin.file: + path: "{{ talk_docker_compose_dir }}/{{ item }}" + state: directory + mode: "0755" + loop: + - signaling + - janus + +- name: Create signaling data directory + ansible.builtin.file: + path: "{{ talk_docker_volume_dir }}/signaling/data" + state: directory + mode: "0755" + +- name: Ensure proxy network exists (created externally by Traefik role normally) + community.docker.docker_network: + name: "{{ talk_traefik_network }}" + state: present + +- name: Render signaling server.conf + ansible.builtin.template: + src: server.conf.j2 + dest: "{{ talk_docker_compose_dir }}/signaling/server.conf" + mode: "0640" + no_log: true + notify: Restart signaling stack + +- name: Render Janus main config + ansible.builtin.template: + src: janus.jcfg.j2 + dest: "{{ talk_docker_compose_dir }}/janus/janus.jcfg" + mode: "0644" + notify: Restart signaling stack + +- name: Render Janus websockets transport config + ansible.builtin.template: + src: janus.transport.websockets.jcfg.j2 + dest: "{{ talk_docker_compose_dir }}/janus/janus.transport.websockets.jcfg" + mode: "0644" + notify: Restart signaling stack + +- name: Render Janus logger config + ansible.builtin.template: + src: janus.logger.jcfg.j2 + dest: "{{ talk_docker_compose_dir }}/janus/janus.logger.jcfg" + mode: "0644" + notify: Restart signaling stack + +- name: Render docker-compose.yml + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ talk_docker_compose_dir }}/docker-compose.yml" + mode: "0644" + notify: Restart signaling stack + +- name: Start signaling stack + community.docker.docker_compose_v2: + project_src: "{{ talk_docker_compose_dir }}" + state: present diff --git a/roles/talk/templates/docker-compose.yml.j2 b/roles/talk/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..f207186 --- /dev/null +++ b/roles/talk/templates/docker-compose.yml.j2 @@ -0,0 +1,124 @@ +{# Build the effective extra_hosts list once #} +{% set _extra_hosts = [] %} +{% if talk_nextcloud_extra_host_ip | length > 0 %} +{% set _ = _extra_hosts.append((talk_nextcloud_url | urlsplit('hostname')) ~ ':' ~ talk_nextcloud_extra_host_ip) %} +{% endif %} +{% for h in talk_extra_hosts %} +{% set _ = _extra_hosts.append(h) %} +{% endfor %} +networks: + {{ talk_traefik_network }}: + external: true + {{ talk_internal_network }}: + driver: bridge + +services: + nats: + image: {{ talk_nats_image }} + container_name: nats + restart: unless-stopped +{% if _extra_hosts | length > 0 %} + extra_hosts: +{% for h in _extra_hosts %} + - "{{ h }}" +{% endfor %} +{% endif %} + command: > + -js + -m 8222 + -p 4222 + healthcheck: + test: ["CMD", "nc", "-z", "localhost", "4222"] + interval: 10s + timeout: 3s + retries: 10 + networks: + - {{ talk_internal_network }} + + janus: + image: {{ talk_janus_image }} + container_name: janus + restart: unless-stopped +{% if _extra_hosts | length > 0 %} + extra_hosts: +{% for h in _extra_hosts %} + - "{{ h }}" +{% endfor %} +{% endif %} + environment: + PUBLIC_IP: "{{ talk_janus_public_ip }}" + RTP_RANGE: "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}" + volumes: + - ./janus/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro + - ./janus/janus.transport.websockets.jcfg:/usr/local/etc/janus/janus.transport.websockets.jcfg:ro + - ./janus/janus.logger.jcfg:/usr/local/etc/janus/janus.logger.jcfg:ro + networks: + - {{ talk_internal_network }} + ports: + - "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}:{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}/udp" + - "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}:{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}/tcp" + ulimits: + nofile: + soft: 65536 + hard: 65536 + + signaling: + image: {{ talk_signaling_image }} + container_name: signaling + restart: unless-stopped + depends_on: + nats: + condition: service_healthy +{% if _extra_hosts | length > 0 %} + extra_hosts: +{% for h in _extra_hosts %} + - "{{ h }}" +{% endfor %} +{% endif %} + volumes: + - ./signaling/server.conf:/config/server.conf:ro + - {{ talk_docker_volume_dir }}/signaling/data:/var/lib/signaling + networks: + - {{ talk_traefik_network }} + - {{ talk_internal_network }} + labels: + - traefik.enable=true + - traefik.docker.network={{ talk_traefik_network }} + + # Public WebSocket route (/spreed) + - traefik.http.routers.signal-public.rule=Host(`{{ talk_domain }}`) && PathPrefix(`/spreed`) + - traefik.http.routers.signal-public.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }} +{% if talk_use_ssl %} + - traefik.http.routers.signal-public.tls=true + - traefik.http.routers.signal-public.tls.certresolver={{ talk_cert_resolver }} +{% endif %} + - traefik.http.routers.signal-public.service=signal-svc + - traefik.http.routers.signal-public.middlewares=signal-ws + + # Public backend API route (/api/) + - traefik.http.routers.signal-backend.rule=Host(`{{ talk_domain }}`) && PathPrefix(`/api/`) + - traefik.http.routers.signal-backend.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }} +{% if talk_use_ssl %} + - traefik.http.routers.signal-backend.tls=true + - traefik.http.routers.signal-backend.tls.certresolver={{ talk_cert_resolver }} +{% endif %} + - traefik.http.routers.signal-backend.service=signal-svc + +{% if talk_internal_domain | length > 0 %} + # Internal split-horizon route (full host on int domain, WebSocket-aware) + - traefik.http.routers.signal-int.rule=Host(`{{ talk_internal_domain }}`) + - traefik.http.routers.signal-int.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }} +{% if talk_use_ssl %} + - traefik.http.routers.signal-int.tls=true + - traefik.http.routers.signal-int.tls.certresolver={{ talk_cert_resolver }} +{% endif %} + - traefik.http.routers.signal-int.service=signal-svc + - traefik.http.routers.signal-int.middlewares=signal-ws +{% endif %} + + # Common service + - traefik.http.services.signal-svc.loadbalancer.server.port=8181 + + # WebSocket upgrade headers + - traefik.http.middlewares.signal-ws.headers.customrequestheaders.Upgrade=websocket + - traefik.http.middlewares.signal-ws.headers.customrequestheaders.Connection=Upgrade diff --git a/roles/talk/templates/janus.jcfg.j2 b/roles/talk/templates/janus.jcfg.j2 new file mode 100644 index 0000000..7c0a3bc --- /dev/null +++ b/roles/talk/templates/janus.jcfg.j2 @@ -0,0 +1,28 @@ +general: { + configs_folder = "/usr/local/etc/janus" + log_to_stdout = true +} + +nat: { + nat_1_1_mapping = "{{ talk_janus_public_ip }}" + ice_lite = {{ talk_janus_ice_lite | string | lower }} + ice_tcp = {{ talk_janus_ice_tcp | string | lower }} + + stun_server = "{{ talk_janus_stun_server }}" + stun_port = {{ talk_janus_stun_port }} + + rtp_port_range = "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}" +} + +media: { + rtp_port_range = "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}" +} + +transports: { + websockets: { + ws = true + ws_port = 8188 + ws_interface = "0.0.0.0" + ws_ip = "0.0.0.0" + } +} diff --git a/roles/talk/templates/janus.logger.jcfg.j2 b/roles/talk/templates/janus.logger.jcfg.j2 new file mode 100644 index 0000000..6e1c4e4 --- /dev/null +++ b/roles/talk/templates/janus.logger.jcfg.j2 @@ -0,0 +1,3 @@ +general: { + enabled = true +} diff --git a/roles/talk/templates/janus.transport.websockets.jcfg.j2 b/roles/talk/templates/janus.transport.websockets.jcfg.j2 new file mode 100644 index 0000000..b5cb5a7 --- /dev/null +++ b/roles/talk/templates/janus.transport.websockets.jcfg.j2 @@ -0,0 +1,7 @@ +general: { + ws = true + ws_port = 8188 + ws_interface = "0.0.0.0" + ws_pingpong_trigger = 60 + ws_pingpong_timeout = 30 +} diff --git a/roles/talk/templates/server.conf.j2 b/roles/talk/templates/server.conf.j2 new file mode 100644 index 0000000..6d86c0a --- /dev/null +++ b/roles/talk/templates/server.conf.j2 @@ -0,0 +1,33 @@ +[http] +listen = 0.0.0.0:8181 +base_url = https://{{ talk_domain }} + +[backend] +backends = cloud + +[cloud] +secret = {{ talk_backend_secret }} +url = {{ talk_nextcloud_url }} + +[nats] +url = nats://nats:4222 + +[mcu] +type = {{ talk_mcu_type }} +url = ws://janus:8188/ + +[sessions] +hashkey = {{ talk_session_hashkey }} +blockkey = {{ talk_session_blockkey }} + +[turn] +servers = {{ talk_turn_servers }} +realm = {{ talk_turn_realm }} +{% if talk_turn_apikey | length > 0 %} +apikey = {{ talk_turn_apikey }} +{% endif %} +secret = {{ talk_turn_secret }} + +[app] +trustedproxies = {{ talk_trusted_proxies | join(',') }} +allowedhosts = {{ talk_allowed_hosts | join(',') }} diff --git a/roles/talk/tests/inventory b/roles/talk/tests/inventory new file mode 100644 index 0000000..eec845d --- /dev/null +++ b/roles/talk/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost \ No newline at end of file diff --git a/roles/talk/tests/test.yml b/roles/talk/tests/test.yml new file mode 100644 index 0000000..a3c7d07 --- /dev/null +++ b/roles/talk/tests/test.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - talk diff --git a/roles/talk/vars/main.yml b/roles/talk/vars/main.yml new file mode 100644 index 0000000..fedd529 --- /dev/null +++ b/roles/talk/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for httpbin