feat(talk/turn/signaling/hpb): add role for Talk with backend services

This commit is contained in:
Tobias Wüst 2026-05-22 01:10:56 +02:00
parent 78095cca1d
commit 27255a4bfa
25 changed files with 930 additions and 0 deletions

69
roles/coturn/README.md Normal file
View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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: []

110
roles/coturn/tasks/main.yml Normal file
View file

@ -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

View file

@ -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 %}

View file

@ -0,0 +1,2 @@
#SPDX-License-Identifier: MIT-0
localhost

View file

@ -0,0 +1,6 @@
#SPDX-License-Identifier: MIT-0
---
- hosts: localhost
remote_user: root
roles:
- coturn

View file

@ -0,0 +1,3 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for httpbin

View file

@ -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: "<hpb_shared_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: "<turn_shared_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

View file

@ -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

View file

@ -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 }}"

78
roles/talk/README.md Normal file
View file

@ -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 <talk_domain> <talk_backend_secret>` — 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.)

View file

@ -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}/<name>; 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: []

View file

@ -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

15
roles/talk/meta/main.yml Normal file
View file

@ -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: []

85
roles/talk/tasks/main.yml Normal file
View file

@ -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

View file

@ -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

View file

@ -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"
}
}

View file

@ -0,0 +1,3 @@
general: {
enabled = true
}

View file

@ -0,0 +1,7 @@
general: {
ws = true
ws_port = 8188
ws_interface = "0.0.0.0"
ws_pingpong_trigger = 60
ws_pingpong_timeout = 30
}

View file

@ -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(',') }}

View file

@ -0,0 +1,2 @@
#SPDX-License-Identifier: MIT-0
localhost

View file

@ -0,0 +1,6 @@
#SPDX-License-Identifier: MIT-0
---
- hosts: localhost
remote_user: root
roles:
- talk

3
roles/talk/vars/main.yml Normal file
View file

@ -0,0 +1,3 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for httpbin