Compare commits

..

27 commits

Author SHA1 Message Date
Simon Bärlocher
3236ca332f
docs(collection): document all roles and fix metadata drift
Replace ansible-galaxy init placeholders across the collection and
correct documentation that drifted from the code, after a multi-agent
review of every role README against its defaults, tasks and templates.

Collection level:
- README: role table for all 16 roles, requirements and role-ordering
- galaxy.yml: declare community.docker and community.general deps,
  real description/tags/urls; normalize license to MIT-0
- meta/runtime.yml: requires_ansible '>=2.15.0'
- plugins/README: document the homarr_layout filter and
  garage_credentials lookup instead of scaffold boilerplate

Per-role meta/main.yml and README for the placeholder roles
(389ds, authentik, authentik_outpost_ldap, base, collabora, drawio,
garage, homarr, httpbin, keycloak, nextcloud, opencloud, traefik).

Correctness fixes found during review:
- keycloak: wrong domain default, drop invented keycloak_cert_resolver,
  document the provisioning feature
- garage: root_domain is .s3.<first-entry>, not the bare domain
- opnform: jwt/front_api secrets use `openssl rand -hex 32`; align the
  validation fail_msg in tasks/main.yml accordingly
- send: S3 example references garage_s3_domains[0] (was singular)
- opencloud: document required opencloud_wopi_domain

License normalized to MIT-0 across galaxy.yml, role meta and READMEs to
match the SPDX headers.
2026-05-27 23:12:24 +02:00
Simon Bärlocher
19864d79b2
feat(services): multi-domain routing, split-horizon and OIDC hardening
Bundle of cross-role changes for the gymb services deployment:

- Traefik routers: OR-combine opnform/homarr/bookstack Host rules with new
  *_extra_domains (internal *.int.* FQDNs for a DMZ reverseproxy), and emit
  tls.certresolver only when traefik_cert_mode == acme (drawio, homarr,
  opnform, send).
- Split-horizon: bookstack_extra_hosts / opnform_extra_hosts add container
  /etc/hosts overrides so containers reach the IdP public FQDN over the LAN.
- bookstack: assert the OIDC issuer resolves concretely (reject "//v2.0"),
  allowing non-Entra IdPs that override bookstack_oidc_issuer.
- homarr: derive the bcrypt salt from the password digest so the admin hash
  is idempotent — no spurious template changes / container restarts.
- opnform: PATCH an existing OIDC connection instead of skipping (applies
  corrected inventory on re-run); add OIDC_FORCE_LOGIN (enabled only after
  bootstrap) and an optional direct-SSO ingress entrypoint.

Docs: READMEs and meta/argument_specs.yml updated for all new variables.
2026-05-27 23:12:24 +02:00
Simon Bärlocher
1dcff92240
docs(roles): add argument_specs and README for traefik, authentik, drawio, garage, nextcloud
Each of the five roles touched in this branch now ships:

* meta/argument_specs.yml: typed schema for every variable in
  defaults/main.yml plus the optional inputs surfaced via this
  branch (traefik_extra_hosts, authentik_host_rewrite_domains,
  authentik_proxy_apps.mode / .allowed_groups, drawio_extra_domains,
  drawio_authentik_forward_auth*, garage_webui_authentik_forward_auth*).
  All five specs load cleanly through ansible-core's
  ArgumentSpecValidator.

* README.md: replaces the ansible-galaxy boilerplate (where it was
  still in place) with a focused write-up — service vars, required
  secrets, ForwardAuth/idempotency notes, dependencies, and a working
  example playbook. authentik and garage READMEs are rewritten to cover
  the new knobs while preserving their existing content.
2026-05-27 23:12:24 +02:00
Simon Bärlocher
a9c33baed9
feat(drawio): support extra hostnames via drawio_extra_domains
Add drawio_extra_domains (list, default empty). The traefik Host rule
on the drawio router now expands to Host(<canonical>) || Host(<extra>)
... so the same container can answer on additional FQDNs — e.g. an
internal *.int.* name so a DMZ reverse-proxy can reach drawio via a
backend hostname covered by the local traefik cert.

Empty by default; behaviour unchanged for existing inventories.
2026-05-27 23:12:24 +02:00
Simon Bärlocher
60464e6d23
fix(nextcloud): in-container patch for UserConfig::getValueBool TypeError
nextcloud/server#59629: under PHP 8.x with OPcache,
UserConfig::getValueBool() passes a non-string from getTypedValue()
straight into strtolower(), throwing a TypeError on every authenticated
request once user_ldap is involved. Fix landed in master (PR #59646)
but no stable33 backport made it into 33.0.4.

Discover all compose-managed nextcloud containers, check whether the
`strtolower((string)` cast is already present, and `sed` it into
`lib/private/Config/UserConfig.php` on the ones that still ship the
broken version. Idempotent via grep guard so re-runs are no-ops.

Remove this block once the deployed image >= 33.0.4 ships the upstream fix.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
f0cd8ba432
fix(nextcloud): make occ-driven config tasks idempotent
Every `occ config:app:set` / `ldap:set-config` / `notify_push:setup`
call previously fired on every play, marking changed even when the
stored value already matched. Now we read the current value first and
only invoke the setter when it differs:

* richdocuments (collabora): pre-read wopi_url, public_wopi_url,
  disable_certificate_verification, wopi_allowlist into a fact map;
  guard each `config:app:set` and tag `richdocuments:activate-config`
  with `changed_when: false` since it's a discovery refresh.

* drawio: same pattern for DrawioUrl, DrawioTheme, DrawioOffline,
  comparing as strings (occ stores booleans as "1"/"0").

* user_ldap: pre-read `ldap:show-config s01 --output=json`, parse JSON
  defensively (occ logs interleave on stderr), and skip per-key
  `ldap:set-config` calls when the stored value already equals the
  desired one.

* notify_push: skip `notify_push:setup` when the stored base_endpoint
  already matches the computed URL.

* plugins: `app:install`/`app:enable` were treating "already installed/
  enabled" output as a change. Add the negative match to `changed_when`
  so re-runs of a fully-provisioned site report ok rather than changed.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
3855b3e0e7
fix(garage): make bootstrap & provision idempotent across reruns
* bootstrap: `garage layout show` truncates node IDs to 16 chars, but
  the membership check compared against the full hex. After the first
  successful join, subsequent runs no longer found the short ID in
  `layout show` and re-issued `layout assign`, marking the task
  changed every time. Compare against both the truncated and the full
  form so a configured node stays detected. Also tag the read-only
  `garage node id` / `layout show` probes with `changed_when: false`.

* provision keys: the old parser sliced `stdout_lines[1:]` to drop the
  header but missed that INFO log lines and ANSI escapes can interleave
  with table rows. Replace with an explicit `^GK[0-9a-fA-F]+` filter
  after stripping ANSI, so probe-output noise no longer corrupts the
  existing-keys set and triggers spurious `key new` calls.

* provision buckets: same class of fix — match `^[0-9a-f]{16}\s` data
  rows instead of slicing `[2:]`, which broke when the table header
  wasn't exactly two lines.

* provision permissions: pre-read `bucket info` for each (key, bucket)
  pair and only run `bucket allow` when the current `RWO` flag set for
  that key ID doesn't already match the desired permissions. Previously
  `bucket allow` ran unconditionally and reported changed every play.

* `changed_when: false` on all read-only probes (`key list`, `key info`,
  `bucket list`).
2026-05-27 23:12:23 +02:00
Simon Bärlocher
ce50bdb4d3
feat(drawio,garage): optional Authentik ForwardAuth in front of UIs
Add `*_authentik_forward_auth` + `*_authentik_forward_auth_url` knobs to
both roles. When enabled:

* drawio: traefik attaches a ForwardAuth middleware pointing at the
  authentik embedded outpost; unauthenticated requests get redirected
  to log in and downstream sees X-Authentik-* identity headers.

* garage WebUI: same ForwardAuth wiring, and `AUTH_USER_PASS` is dropped
  from the container env so authentik is the only gate. Tasks now key
  the htpasswd hash workflow off `_garage_webui_htpasswd_active`
  (`webui_enabled AND NOT authentik_forward_auth`); when authentik
  fronts the UI we skip hashing entirely. htpasswd hash is also now
  cached on disk and re-verified via `htpasswd -vbB` so unchanged
  passwords stop showing as `changed=true` on every run.

Both knobs default to `false`, preserving existing htpasswd/plain behaviour.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
6411f94cce
feat(authentik): split-horizon host rewrite + proxy-app mode/group bindings
* `authentik_host_rewrite_domains`: extra hostnames that reach the
  authentik container but make it generate URLs (OIDC issuer, reset
  links) as if requested from the canonical `authentik_domains[0]`.
  Each entry gets its own traefik router and a URL-based loadbalancer
  service that disables passHostHeader and pins X-Forwarded-Host via
  middleware, so server-to-server calls on internal FQDNs keep traffic
  in the LAN while the iss claim stays aligned with the public host.
  Uses a network alias on the canonical FQDN so traefik (sharing the
  network) resolves the URL upstream to this very container.

* proxy-app blueprint:
  - `mode` (default `forward_single`) lets callers pick between proxy,
    forward_single and forward_domain providers in one template.
  - `allowed_groups`: when set, emit one PolicyBinding per group on
    the application; authentik OR-evaluates bindings, so users in any
    listed group pass and others are denied.

Existing inventories with an empty list see no behavioural change.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
99d8968a2e
feat(traefik): configurable extra_hosts for container DNS overrides
Add `traefik_extra_hosts` (list of `host:ip`) that maps straight into
the traefik container's compose `extra_hosts`. Needed when a downstream
middleware (e.g. ForwardAuth to authentik on a sibling LAN) has to
resolve a public FQDN to an internal IP because the DMZ doesn't hairpin
the public address back inside.

Empty by default; behaviour unchanged for existing inventories.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
2104e5fe7d
feat: drop blanket recreates, ACME-DNS knobs, notify_push override
- Drop `recreate: always` from collabora/drawio/homarr/opencloud/traefik
  handlers and the authentik_outpost_ldap start task. `up -d` with
  `state: present` already recreates exactly the services whose
  compose definition changed; the blanket recreate was forcing
  restarts even when nothing relevant moved.
- Rewrite the `*_domains` Traefik Host loop to the `Host(\`a\`) ||
  Host(\`b\`)` form across authentik/collabora/garage/nextcloud so the
  rule still matches when traefik can't normalize the comma-form into
  the same canonical shape.
- Traefik: add `traefik_acme_tcp_only` (sets LEGO_EXPERIMENTAL_DNS_TCP_ONLY)
  and `traefik_acme_disable_ans_checks` (disables lego's authoritative-NS
  propagation check) for environments where the DNS path between the
  traefik container and the zone's nameservers is constrained.
- Traefik DMZ collector: two-step merge so a `traefik_dmz_exposed_services`
  entry that sets its own `backend_host` wins over the host fallback;
  lets a route target an internal FQDN covered by the backend cert's
  SANs instead of the raw IP.
- Nextcloud: add `nextcloud_notify_push_domain` override for the
  `occ notify_push:setup` call so the setup check can hit an internal
  FQDN instead of hairpinning through the DMZ. Push router now matches
  every entry in `nextcloud_domains`.
- Nextcloud: also %2F-escape slashes in the postgres user/password
  inside the notify_push DATABASE_URL.
2026-05-27 23:12:23 +02:00
Simon Bärlocher
c3cf779532
feat: domain list refactor + demo-gymburgdorf fixes
- Refactor: collapse `*_domain` + `*_extra_domains` into a single
  `*_domains` list across authentik, collabora, garage and nextcloud
  roles. First entry is the canonical FQDN (used for OVERWRITEHOST,
  BASE_URL, notify_push setup and garage root_domain).
- Authentik blueprint: guard the OAuth sources block so an empty
  `authentik_login_sources` no longer renders an invalid YAML key.
- Nextcloud: introduce `nextcloud_collabora_public_domain` and set
  Collabora's `public_wopi_url` separately from the server-to-server
  `wopi_url` so browsers can reach Collabora via the public name while
  Nextcloud still talks to it on the internal one.
- Nextcloud: URL-encode the postgres user/password in DATABASE_URL.
2026-05-27 23:12:22 +02:00
Simon Bärlocher
c11f019aae
fix(send): assert S3 credentials when storage backend is s3
When send_storage_backend=s3 the role previously deployed the container
with whatever was in send_s3_* (often empty strings from the defaults).
The container would then start, accept uploads, and fail to persist
anything silently. Same pattern as the validate blocks in coturn,
talk, bookstack and opnform: fail fast at task time with a clear error
that points at the four missing variables.

Skipped entirely when send_storage_backend=local (the default).
2026-05-26 15:40:21 +02:00
Simon Bärlocher
a492c3ee04
docs(send): add meta/argument_specs.yml
29 typed options with full defaults coverage (no required: true marks —
the role works with an empty S3 config when storage_backend=local).
Documents the send_domains list convention, the local-vs-s3 storage
choice, the timing/size limits and the Traefik / network wiring.

Loads through ansible-core's ArgumentSpecValidator. Matches the spec
convention used by the other roles in this collection.
2026-05-26 15:38:35 +02:00
Simon Bärlocher
b19ac2270a
fix(send): use Traefik v3 OR-syntax for multi-domain Host rule
The router rule joined send_domains with ', ' which is the v2 syntax
('Host(`a`, `b`)'). Traefik v3 expects each Host() to be its own
matcher joined with the explicit '||' OR operator. With v3 the comma
form is silently ignored — only the first host actually matches.

Match the pattern already used in the authentik, drawio and nextcloud
roles in this collection.
2026-05-26 15:38:34 +02:00
Simon Bärlocher
e1d604effc
fix(send): self-review fixes (FQCN, min_ansible_version str)
* tasks/main.yml: prefix all builtin modules with ansible.builtin
  (file, template) — silences ansible-lint fqcn[action-core] and
  matches the convention used by the other roles in this collection.

* meta/main.yml: change min_ansible_version from the float 2.14 to
  the string '2.14'. ansible-galaxy's schema requires a string here
  (ansible-lint schema[meta] complains otherwise — same fix I just
  applied to the opnform role).
2026-05-26 15:38:34 +02:00
Simon Bärlocher
4655c8f037
feat(send): add role for self-hosted Send file-share service
Deploys timvisee/send with a Redis backend behind Traefik. Supports
local-disk or S3 storage (e.g. via the garage role). Uses the shared
`*_domains` list convention so the router can accept internal *.int.*
names alongside the canonical BASE_URL host.
2026-05-26 15:38:34 +02:00
Simon Bärlocher
9a9039c4d3
docs(talk,coturn): add meta/argument_specs.yml
* coturn: 31 typed options including the 3 cert modes (acme/file/
  selfsigned), the RFC2136 acme.sh sidecar config and challenge alias
  subschema. coturn_static_auth_secret marked required.

* talk: 34 typed options covering the signaling/janus/nats triplet,
  TURN integration, MCU (janus) tuning, trusted-proxy CIDRs and the
  extra_hosts pin. talk_backend_secret, talk_turn_secret,
  talk_session_hashkey and talk_session_blockkey marked required.

Both specs load cleanly through ansible-core's ArgumentSpecValidator,
have 100% defaults/spec coverage, and match the convention introduced
for the other roles in this collection.
2026-05-26 15:35:19 +02:00
Simon Bärlocher
dc8f1e2ecd
fix(talk,coturn): correct vars file header (was 'httpbin')
Both new roles had 'vars file for httpbin' as the header comment in
vars/main.yml — copy-paste artefact from the httpbin role template.
Files are otherwise empty. Reviewer flagged both inline (PR review
comments 229 and 230).
2026-05-26 15:35:18 +02:00
05fb62c75d
feat(talk/turn/signaling/hpb): add role for Talk with backend services 2026-05-26 15:35:18 +02:00
Simon Bärlocher
2c2dbbc648
docs(bookstack): add meta/argument_specs.yml
47 typed options covering the full defaults file plus the OIDC and
backup-timer subsystems. The three secrets the role asserts on
(db_root_password, db_password, admin_password) are marked
required: true so ansible refuses the play with a clear error before
the validate task even runs.

Loads cleanly through ansible-core's ArgumentSpecValidator with 100%
defaults/spec coverage. Matches the spec convention used by traefik,
authentik, drawio, garage, nextcloud, opnform, coturn, talk and send.
2026-05-26 15:30:36 +02:00
951b1822fe
feat(bookstack): add role for self-hosted BookStack deployment
Deploy BookStack with linuxserver.io images behind Traefik, including
Entra ID OIDC SSO support and a daily backup timer.

Stack:
- lscr.io/linuxserver/bookstack:version-v26.03.3
- lscr.io/linuxserver/mariadb:11.4.9
- Traefik labels for websecure entrypoint on internal network
- Healthcheck via mariadb-admin ping (LSIO image lacks healthcheck.sh)

Features:
- Persistent APP_KEY generated on first run, stored in volume dir
- Optional OIDC SSO via Microsoft Entra ID (configurable per-instance)
- Idempotent admin user creation with DB-based existence check
- Daily systemd timer backup (DB dump + uploads tar + APP_KEY)
  with configurable retention

Implementation notes:
- DB queries use --protocol=tcp with the app user because root@localhost
  uses unix_socket auth in the LSIO MariaDB image (no password) and
  root@% does not exist
- docker_container_exec uses argv: (list) instead of command: (string)
  to avoid argument-splitting issues
- Migration-wait task ensures users table exists before admin check,
  since /login returns 200 before Laravel migrations complete
- no_log: true on all tasks that reference DB or admin passwords
- artisan absolute path (/app/www/artisan) because LSIO image WORKDIR
  is not the app directory

Adds bookstack route to DMZ Traefik service registry.
2026-05-26 15:30:21 +02:00
Simon Bärlocher
30f3c16b59
docs(opnform): add meta/argument_specs.yml
50 typed options covering the full defaults file plus the OIDC subschema
(group_role_mappings with idp_group + role choices). Required secrets
(app_key, jwt_secret, front_api_secret, db_password) marked
required: true so ansible refuses the play with a clear error before
the validate task even runs.

Loads cleanly through ansible-core's ArgumentSpecValidator. Matches the
spec convention introduced for traefik, authentik, drawio, garage and
nextcloud.
2026-05-26 14:58:36 +02:00
Simon Bärlocher
fb81f60f9d
fix(opnform): drop production-looking secrets from defaults
opnform_app_key, opnform_jwt_secret, opnform_front_api_secret and
opnform_db_password shipped as real base64 strings in defaults — they
look like production secrets that just happen to be public. Set all
four to '' and rely on the existing Validate task (and the new
argument_specs marking them required) to fail fast when an inventory
forgets to override them.

Mirror the docstring comment to show how to generate each one with
openssl.
2026-05-26 14:58:18 +02:00
Simon Bärlocher
48d12a1b4a
fix(opnform): address review feedback on vars header and meta boilerplate
* vars/main.yml: header was 'vars file for homarr' (copy-paste from the
  homarr role). Fixed to 'vars file for opnform'. File body is empty.
* meta/main.yml: replace ansible-galaxy init boilerplate with real
  metadata — author, description, license (MIT-0), min_ansible_version
  set to '2.15' as a string (galaxy schema requires str), galaxy_tags
  for discovery, and an empty dependencies list.

The third inline finding (dead roles/opnform/templates/compose.yml.j2)
is resolved by dropping the WIP commit a6f301e during the rebase rather
than removing it in a separate commit — the file no longer exists in
the rebased history.
2026-05-26 14:58:10 +02:00
03af64ca2c
feat(opnform)!: add admin and OIDC bootstrap, rename role to lowercase
Rename roles/OpnForm → roles/opnform so the role resolves as
  digitalboard.core.opnform (Ansible collection convention is
  lowercase). Update tests/test.yml reference accordingly.

  Add automated admin user creation via POST /api/register, gated on
  opnform_admin_email + opnform_admin_password. Idempotent through a
  prior login probe. Without these vars the manual setup page flow is
  preserved.

  Add automated OIDC IdentityConnection setup via the per-workspace
  /api/open/workspaces/{id}/oidc-connections endpoint, gated on
  opnform_oidc_enabled. Hard-coupled to the admin bootstrap (the API
  requires an authenticated admin token); validation block fails fast
  if OIDC is enabled without admin credentials. Supports both an
  explicit opnform_oidc_group_role_mappings list and a fallback
  opnform_oidc_admin_group convenience var.

  Convert opnform_oidc_scopes from space-separated string to YAML list
  to match OpnForm's API expectation. Rewrite README "First login" and
  "OIDC setup" sections to reflect that self-hosted OpnForm does not
  ship a pre-seeded admin and to document the new bootstrap paths.
  BREAKING CHANGE: opnform_oidc_scopes changed from space-separated
  string to YAML list. Inventories that override it must update from
  "openid profile email" to [openid, profile, email].
2026-05-26 14:54:35 +02:00
53e80ad7be
chore: add new role for OpnForm 2026-05-26 14:47:57 +02:00
27 changed files with 1239 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,148 @@
---
argument_specs:
main:
short_description: Deploy a coturn TURN/STUN server with optional acme.sh sidecar.
description:
- "Renders a Docker Compose stack for coturn running in
C(network_mode: host), with an optional C(acme.sh) sidecar that
issues + renews a public TLS certificate via RFC2136 / nsupdate
and restarts coturn on renewal."
- Designed to be paired with the C(digitalboard.core.talk) role
(Nextcloud Talk High Performance Backend).
options:
docker_compose_base_dir:
type: path
default: /etc/docker/compose
docker_volume_base_dir:
type: path
default: /srv/data
coturn_service_name:
type: str
default: coturn
coturn_docker_compose_dir:
type: path
coturn_docker_volume_dir:
type: path
coturn_image:
type: str
default: "coturn/coturn:4.6.2-r5-alpine"
coturn_acme_image:
type: str
default: "neilpang/acme.sh:3.1.0"
coturn_realm:
type: str
default: stun.example.test
description: Public DNS name used for the TURN realm and the public certificate.
coturn_internal_realm:
type: str
default: ''
description:
- Optional second DNS name issued on the same certificate, used for
split-horizon internal access (e.g. C(stun.int.example.test)).
coturn_listening_port:
type: int
default: 3478
description: TURN/STUN port (TCP + UDP). IANA standard is 3478.
coturn_tls_listening_port:
type: int
default: 5349
description: TURNS port (TCP + UDP). IANA standard is 5349.
coturn_min_relay_port:
type: int
default: 49160
coturn_max_relay_port:
type: int
default: 49200
coturn_external_ip:
type: str
default: ''
description:
- coturn C(--external-ip) value. Format C("PUBLIC_IP") or
C("PUBLIC_IP/PRIVATE_IP"). Must be set in host_vars for production.
coturn_listening_ip:
type: str
default: '0.0.0.0'
coturn_static_auth_secret:
type: str
required: true
description:
- Shared secret used by the HPB signaling server to mint short-lived
TURN credentials. Default lookup reads
C(playbooks/secrets/<host>/coturn_static_auth_secret).
coturn_extra_args:
type: list
elements: str
default: []
description: Additional CLI flags appended verbatim to the container C(command:).
coturn_cert_mode:
type: str
choices: [acme, file, selfsigned]
default: file
description:
- C(acme) runs an acme.sh sidecar that issues + renews via RFC2136
and restarts coturn. C(file) assumes a certificate already lives
on the host (you manage it). C(selfsigned) generates one on first
run (vagrant/dev only).
coturn_cert_dir:
type: path
coturn_cert_file:
type: str
default: fullchain.cer
coturn_key_file:
type: str
description: Defaults to C("{{ coturn_realm }}.key").
coturn_acme_email:
type: str
default: admin@example.test
coturn_acme_directory:
type: str
default: https://acme-v02.api.letsencrypt.org/directory
coturn_acme_keylength:
type: str
default: ec-256
choices: [ec-256, ec-384, '2048', '3072', '4096']
coturn_acme_dnssleep:
type: int
default: 60
coturn_acme_data_dir:
type: path
coturn_acme_nsupdate_server:
type: str
default: ''
description: Authoritative nameserver acme.sh sends C(nsupdate) packets to.
coturn_acme_nsupdate_server_ip:
type: str
default: ''
description: Optional C(extra_hosts) pin (string IP) for the nsupdate server.
coturn_acme_nsupdate_zone:
type: str
default: ''
description: Delegated challenge zone (e.g. C(example._acme.example.test)).
coturn_acme_challenge_aliases:
type: list
elements: dict
default: []
description:
- Per-name challenge alias zones (one entry per SAN). When empty,
built automatically as C({{ realm }}._acme.{{ zone-tail }}).
options:
name:
type: str
required: true
description: SAN the challenge is for.
alias:
type: str
required: true
description: CNAME target where the C(_acme-challenge) TXT lives.
coturn_acme_nsupdate_key_src:
type: path
description: Path of the TSIG key file on the controller, mounted into the acme container.

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 coturn

View file

@ -72,6 +72,26 @@ nextcloud_notify_push_image: "icewind1991/notify_push:1.3.1"
# router matches it.
# nextcloud_notify_push_domain: "cloud.int.example.com"
# 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

@ -127,3 +127,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

View file

@ -0,0 +1,161 @@
---
argument_specs:
main:
short_description: Deploy the Nextcloud Talk High Performance Backend (HPB) stack.
description:
- Renders a Docker Compose stack with C(nextcloud-spreed-signaling)
(Strukturag), C(janus-gateway) (canyan build) and C(nats) (internal
message broker) behind Traefik.
- Designed to be paired with the C(digitalboard.core.coturn) role
(TURN/STUN) and registered in Nextcloud via
C(digitalboard.core.nextcloud)'s C(talk.yml) task.
options:
docker_compose_base_dir:
type: path
default: /etc/docker/compose
docker_volume_base_dir:
type: path
default: /srv/data
talk_service_name:
type: str
default: signaling
talk_docker_compose_dir:
type: path
talk_docker_volume_dir:
type: path
talk_signaling_image:
type: str
default: "strukturag/nextcloud-spreed-signaling:1.3.4"
talk_janus_image:
type: str
default: "canyan/janus-gateway:1.2.4"
talk_nats_image:
type: str
default: "nats:2.10-alpine"
talk_traefik_network:
type: str
default: proxy
talk_internal_network:
type: str
default: hpb_internal
talk_use_ssl:
type: bool
default: true
talk_cert_resolver:
type: str
default: dns
talk_domain:
type: str
default: signaling.example.test
description: Public domain (typically routed through the DMZ Traefik).
talk_internal_domain:
type: str
default: ''
description:
- Optional split-horizon C(*.int.*) domain for server-to-server
traffic (e.g. C(signaling.int.example.test)).
talk_nextcloud_url:
type: str
default: https://cloud.example.test
description: Nextcloud base URL the HPB talks back to. Must be reachable from the HPB container.
talk_nextcloud_extra_host_ip:
type: str
default: ''
description:
- Pin the Nextcloud hostname to a backend IP via C(extra_hosts) to bypass
DMZ hairpin / SNI issues. Empty disables the pin.
talk_backend_secret:
type: str
required: true
description:
- HMAC secret shared with Nextcloud Talk. Default lookup reads
C(playbooks/secrets/<host>/talk_backend_secret).
talk_turn_secret:
type: str
required: true
description:
- Shared secret with coturn (must match C(coturn_static_auth_secret)
on the TURN host). Default lookup reads
C(playbooks/secrets/<host>/talk_turn_secret).
talk_turn_servers:
type: str
default: "turns:stun.example.test:5349?transport=tcp,turn:stun.example.test:3478"
description:
- TURN server URI list as understood by the signaling server.
Override to C(:443) when coturn binds on 443 in restrictive networks.
talk_turn_realm:
type: str
default: stun.example.test
talk_turn_apikey:
type: str
default: ''
description: Optional explicit API key; when empty a random one is generated on first run.
talk_session_hashkey:
type: str
required: true
description:
- 32-byte hex string. Default lookup reads
C(playbooks/secrets/<host>/talk_session_hashkey).
talk_session_blockkey:
type: str
required: true
description:
- 32-byte hex string. Default lookup reads
C(playbooks/secrets/<host>/talk_session_blockkey).
talk_mcu_type:
type: str
choices: [janus]
default: janus
talk_janus_public_ip:
type: str
default: ''
description: Must be set in host_vars. Goes into janus C(nat_1_1_mapping).
talk_janus_rtp_port_min:
type: int
default: 20000
talk_janus_rtp_port_max:
type: int
default: 21000
talk_janus_stun_server:
type: str
default: stun.int.example.test
description: STUN server janus uses for its own ICE candidate gathering.
talk_janus_stun_port:
type: int
default: 5349
talk_janus_ice_lite:
type: bool
default: true
talk_janus_ice_tcp:
type: bool
default: true
talk_trusted_proxies:
type: list
elements: str
default:
- "172.16.0.0/12"
- "192.168.0.0/16"
- "10.0.0.0/8"
talk_allowed_hosts:
type: list
elements: str
default:
- "172.16.0.0/12"
talk_extra_hosts:
type: list
elements: str
default: []
description:
- Extra C(host:ip) entries forwarded to all three containers.
Pre-populated with the Nextcloud pin when
C(talk_nextcloud_extra_host_ip) is set.

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 talk