diff --git a/galaxy.yml b/galaxy.yml index 91cd337..c208d8f 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -19,6 +19,7 @@ readme: README.md authors: - Bert-Jan Fikse - Tobias Wüst +- Simon Bärlocher ### OPTIONAL but strongly recommended # A short summary description of the collection diff --git a/roles/homarr/filter_plugins/homarr_layout.py b/plugins/filter/homarr_layout.py similarity index 100% rename from roles/homarr/filter_plugins/homarr_layout.py rename to plugins/filter/homarr_layout.py diff --git a/roles/bookstack/README.md b/roles/bookstack/README.md new file mode 100644 index 0000000..25fb789 --- /dev/null +++ b/roles/bookstack/README.md @@ -0,0 +1,145 @@ +# Ansible Role: bookstack + +Deploys [BookStack](https://www.bookstackapp.com/) as a self-contained Docker +Compose stack behind Traefik, with its own MariaDB container, OIDC SSO +(Entra ID by default) and a daily systemd-timer driven backup of database +and uploads. + +## Requirements + +- Docker Engine + Compose plugin on the target host +- Traefik already running, with the external network referenced by + `bookstack_traefik_network` (default: `proxy`) +- `community.docker` collection on the controller +- DNS for `bookstack_domain` pointing at the Traefik host + +## Required variables + +The role asserts these are set; the play fails fast if any is empty: + +| Variable | Description | +|---|---| +| `bookstack_db_root_password` | MariaDB root password | +| `bookstack_db_password` | MariaDB user password | +| `bookstack_admin_password` | Initial local admin password | +| `bookstack_oidc_client_id` | Entra ID App Registration ID (if OIDC on) | +| `bookstack_oidc_client_secret` | Entra ID client secret (if OIDC on) | +| `bookstack_entra_tenant_id` | Entra tenant UUID (if OIDC on) | + +Provide via OpenBao lookup, Ansible Vault or `--extra-vars`. Never commit +real secrets. + +## Optional variables + +See `defaults/main.yml`. Frequently overridden: + +- `bookstack_domain`, `bookstack_base_url` +- `bookstack_image`, `bookstack_db_image` (pin in production) +- `bookstack_oidc_enabled` (set `false` to disable OIDC entirely) +- `bookstack_oidc_auto_initiate` (`true` redirects straight to IdP) +- `bookstack_oidc_user_to_groups` (`true` syncs roles from Entra groups) +- `bookstack_backup_enabled`, `bookstack_backup_schedule`, + `bookstack_backup_retention_days` + +## Entra ID app registration + +1. Azure Portal → Entra ID → App registrations → New registration +2. Redirect URI (Web): `https:///oidc/callback` +3. Front-channel logout URL: `https:///logout` +4. Certificates & secrets → New client secret → + `bookstack_oidc_client_secret` +5. For group sync (`bookstack_oidc_user_to_groups: true`): + - Token configuration → Add groups claim → Security groups + - In BookStack, create roles whose **External Auth ID** equals the + Entra group Object ID, so the mapping resolves on first login. + +## What the role does + +| Phase | Action | +|---|---| +| Validate | `assert` all required secrets are set | +| Prepare | install packages, create volume dirs, generate persistent `APP_KEY`, verify Traefik network | +| Deploy | render `docker-compose.yml`, pull images, bring stack up | +| Configure | wait for the app, create the initial local admin via `php artisan bookstack:create-admin` (idempotent) | +| Backup | render `/usr/local/bin/bookstack-backup.sh` + systemd timer (daily 03:00, 14-day retention) | + +## Example playbook + +```yaml +- name: Deploy BookStack service + hosts: bookstack_servers + become: true + roles: + - digitalboard.core.bookstack +``` + +With inventory variables: + +```yaml +# group_vars/bookstack_servers.yml +bookstack_domain: wiki.digitalboard.ch +bookstack_base_url: "https://wiki.digitalboard.ch" +bookstack_entra_tenant_id: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.tenant_id }}" +bookstack_oidc_client_id: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.client_id }}" +bookstack_oidc_client_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.client_secret }}" +bookstack_db_root_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.db_root_password }}" +bookstack_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.db_password }}" +bookstack_admin_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.admin_password }}" +``` + +## Backup / restore + +Backups land in `{{ bookstack_backup_dir }}` (default +`/srv/data/bookstack/backup`) with three files per run: + +- `bookstack-db-.sql.gz` — mariadb-dump +- `bookstack-files-.tar.gz` — uploads, attachments +- `bookstack-appkey-.txt` — APP_KEY (required for restore!) + +Manual trigger: `systemctl start bookstack-backup.service` +Timer status: `systemctl list-timers bookstack-backup.timer` + +Restore procedure: + +1. Stop the stack: `docker compose down` in `bookstack_docker_compose_dir` +2. Restore the APP_KEY: copy the `.txt` content to + `{{ bookstack_docker_volume_dir }}/.app_key` (the key MUST match or + encrypted DB values become unreadable) +3. Start only the DB container, then load the dump: + ```bash + gunzip -c bookstack-db-.sql.gz \ + | docker exec -i bookstack-db \ + mariadb -u root -p"" bookstack + ``` +4. Extract the files: `tar -xzf bookstack-files-.tar.gz -C + {{ bookstack_appdata_dir }}/www/` +5. Bring the stack back up: `docker compose up -d` + +## Notes + +- `bookstack_oidc_auto_initiate: false` (default) shows a login page + with an SSO button alongside the local login form. With `true`, users + go straight to the IdP — the local admin then has to use + `https:///login?email_login=1`. +- `bookstack_oidc_user_to_groups: true` only makes sense once BookStack + roles with the correct **External Auth IDs** (= Entra group Object + IDs) exist; otherwise users lose their role assignment on every login. +- Image tags default to pinned versions; bump them deliberately rather + than chasing `latest`. +- BookStack officially supports MySQL/MariaDB only — no PostgreSQL. + +## License + +MIT diff --git a/roles/bookstack/defaults/main.yml b/roles/bookstack/defaults/main.yml new file mode 100644 index 0000000..3efbadb --- /dev/null +++ b/roles/bookstack/defaults/main.yml @@ -0,0 +1,85 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for bookstack + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# bookstack-specific configuration +bookstack_service_name: bookstack +bookstack_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ bookstack_service_name }}" +bookstack_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ bookstack_service_name }}" +bookstack_appdata_dir: "{{ bookstack_docker_volume_dir }}/appdata" +bookstack_db_data_dir: "{{ bookstack_docker_volume_dir }}/db" +bookstack_backup_dir: "{{ bookstack_docker_volume_dir }}/backup" + +# Service configuration +bookstack_domain: "wiki.local.test" +bookstack_base_url: "https://{{ bookstack_domain }}" + +# Images — pin via inventory in production +bookstack_image: "lscr.io/linuxserver/bookstack:version-v26.03.3" +bookstack_db_image: "lscr.io/linuxserver/mariadb:11.4.9" + +# Traefik configuration +bookstack_traefik_network: "proxy" +bookstack_traefik_certresolver: "le" + +# Timezone / UID +bookstack_tz: "Europe/Zurich" +bookstack_puid: "1000" +bookstack_pgid: "1000" + +# Database configuration +bookstack_db_name: "bookstack" +bookstack_db_user: "bookstack" + +# REQUIRED SECRETS — empty defaults force `assert` to fail until set. +# Provide via OpenBao lookup, Ansible Vault, or extra-vars. +# Never commit real secrets to version control. +# +# Generate with: +# bookstack_db_root_password: openssl rand -base64 32 | tr -d '/+=' +# bookstack_db_password: openssl rand -base64 32 | tr -d '/+=' +# bookstack_admin_password: openssl rand -base64 24 | tr -d '/+=' +bookstack_db_root_password: "" +bookstack_db_password: "" +bookstack_admin_password: "" +bookstack_oidc_client_secret: "" + +# APP_KEY is generated automatically on first run and persisted on the host. +# Set explicitly only if restoring an existing instance. +bookstack_app_key: "" + +# Initial local admin (fallback account, lives alongside OIDC) +bookstack_admin_name: "Admin" +bookstack_admin_email: "admin@local.test" +bookstack_artisan_path: "/app/www/artisan" + +# Mail configuration +bookstack_mail_driver: "smtp" +bookstack_mail_host: "smtp.local.test" +bookstack_mail_port: 587 +bookstack_mail_encryption: "tls" +bookstack_mail_from: "bookstack@local.test" +bookstack_mail_from_name: "BookStack" +bookstack_mail_username: "" +bookstack_mail_password: "" + +# OIDC configuration (Entra ID by default; override `bookstack_oidc_issuer` +# for Keycloak or any other provider) +bookstack_oidc_enabled: false +bookstack_oidc_name: "SSO" +bookstack_entra_tenant_id: "" +bookstack_oidc_issuer: "https://login.microsoftonline.com/{{ bookstack_entra_tenant_id }}/v2.0" +bookstack_oidc_client_id: "" +bookstack_oidc_auto_initiate: false +bookstack_oidc_user_to_groups: false +bookstack_oidc_groups_claim: "groups" +bookstack_oidc_additional_scopes: "openid profile email" + +# Backup configuration +bookstack_backup_enabled: true +bookstack_backup_retention_days: 14 +bookstack_backup_schedule: "*-*-* 03:00:00" diff --git a/roles/bookstack/handlers/main.yml b/roles/bookstack/handlers/main.yml new file mode 100644 index 0000000..eb6a769 --- /dev/null +++ b/roles/bookstack/handlers/main.yml @@ -0,0 +1,19 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for bookstack + +- name: stop bookstack + community.docker.docker_compose_v2: + project_src: "{{ bookstack_docker_compose_dir }}" + state: stopped + listen: restart bookstack + +- name: start bookstack + community.docker.docker_compose_v2: + project_src: "{{ bookstack_docker_compose_dir }}" + state: present + listen: restart bookstack + +- name: reload systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/roles/bookstack/meta/argument_specs.yml b/roles/bookstack/meta/argument_specs.yml new file mode 100644 index 0000000..8546cde --- /dev/null +++ b/roles/bookstack/meta/argument_specs.yml @@ -0,0 +1,194 @@ +--- +argument_specs: + main: + short_description: Deploy BookStack (LSIO image + MariaDB) via Docker Compose. + description: + - Renders a Compose stack for the linuxserver.io BookStack image + with a sibling MariaDB container behind Traefik, then bootstraps + the initial admin user via C(php artisan bookstack:create-admin) + and optionally enables OIDC SSO (Entra ID by default). + - "Persists the Laravel C(APP_KEY) on the host so the same key is + re-used across deploys (a fresh key would orphan all encrypted + database values: 2FA secrets, API tokens, OIDC client_secret)." + - Ships an optional systemd timer that backs up the database dump, + uploads tarball and APP_KEY daily with configurable retention. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + bookstack_service_name: + type: str + default: bookstack + bookstack_docker_compose_dir: + type: path + bookstack_docker_volume_dir: + type: path + bookstack_appdata_dir: + type: path + bookstack_db_data_dir: + type: path + bookstack_backup_dir: + type: path + + bookstack_domain: + type: str + default: wiki.local.test + description: Hostname used in the Traefik Host rule. + bookstack_base_url: + type: str + description: Defaults to C("https://{{ bookstack_domain }}"). + + bookstack_image: + type: str + default: "lscr.io/linuxserver/bookstack:version-v26.03.3" + bookstack_db_image: + type: str + default: "lscr.io/linuxserver/mariadb:11.4.9" + + bookstack_traefik_network: + type: str + default: proxy + bookstack_traefik_certresolver: + type: str + default: le + + bookstack_tz: + type: str + default: Europe/Zurich + bookstack_puid: + type: str + default: "1000" + bookstack_pgid: + type: str + default: "1000" + + bookstack_db_name: + type: str + default: bookstack + bookstack_db_user: + type: str + default: bookstack + bookstack_db_root_password: + type: str + required: true + description: MariaDB C(root) password. Override per-inventory. + bookstack_db_password: + type: str + required: true + description: MariaDB C(bookstack_db_user) password. Override per-inventory. + + bookstack_admin_password: + type: str + required: true + description: + - Password for the local admin user that the role creates via + C(bookstack:create-admin). Lives alongside any OIDC users. + + bookstack_app_key: + type: str + default: '' + description: + - When empty the role generates a persistent C(APP_KEY) on first + run and stores it under C({{ bookstack_docker_volume_dir }}/.app_key). + Override only when restoring an existing instance — a mismatching + key orphans all encrypted database values. + + bookstack_admin_name: + type: str + default: Admin + bookstack_admin_email: + type: str + default: admin@local.test + bookstack_artisan_path: + type: path + default: /app/www/artisan + description: + - Path to BookStack's C(artisan) script inside the container. The + LSIO image's C(WORKDIR) is not the app directory, so this must + be absolute. + + bookstack_mail_driver: + type: str + choices: [smtp, log, sendmail, mailgun, ses, postmark] + default: smtp + bookstack_mail_host: + type: str + default: smtp.local.test + bookstack_mail_port: + type: int + default: 587 + bookstack_mail_encryption: + type: str + choices: [tls, ssl, ''] + default: tls + bookstack_mail_from: + type: str + default: bookstack@local.test + bookstack_mail_from_name: + type: str + default: BookStack + bookstack_mail_username: + type: str + default: '' + bookstack_mail_password: + type: str + default: '' + + bookstack_oidc_enabled: + type: bool + default: false + bookstack_oidc_name: + type: str + default: SSO + description: Display name of the SSO button on the login page. + bookstack_entra_tenant_id: + type: str + default: '' + description: Entra tenant UUID. Required when C(bookstack_oidc_enabled=true). + bookstack_oidc_issuer: + type: str + description: + - OIDC issuer URL. Defaults to the Entra v2 issuer template + built from C(bookstack_entra_tenant_id). Override for + Keycloak or any other provider. + bookstack_oidc_client_id: + type: str + default: '' + description: Required when C(bookstack_oidc_enabled=true). + bookstack_oidc_client_secret: + type: str + default: '' + description: Required when C(bookstack_oidc_enabled=true). + bookstack_oidc_auto_initiate: + type: bool + default: false + description: + - When true users are redirected straight to the IdP and the + local login is reachable only via C(?email_login=1). + bookstack_oidc_user_to_groups: + type: bool + default: false + description: + - When true BookStack syncs roles from the IdP groups claim + on every login. Requires BookStack roles whose + C(External Auth ID) matches the IdP group's Object ID. + bookstack_oidc_groups_claim: + type: str + default: groups + bookstack_oidc_additional_scopes: + type: str + default: openid profile email + + bookstack_backup_enabled: + type: bool + default: true + bookstack_backup_retention_days: + type: int + default: 14 + bookstack_backup_schedule: + type: str + default: "*-*-* 03:00:00" + description: systemd C(OnCalendar) expression for the backup timer. diff --git a/roles/bookstack/meta/main.yml b/roles/bookstack/meta/main.yml new file mode 100644 index 0000000..a6e941d --- /dev/null +++ b/roles/bookstack/meta/main.yml @@ -0,0 +1,25 @@ +galaxy_info: + author: digitalboard + description: Deploy BookStack as a self-contained Docker Compose stack behind Traefik + company: digitalboard + license: MIT + + min_ansible_version: "2.14" + + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble + + galaxy_tags: + - docker + - bookstack + - wiki + - documentation + - digitalboard + +dependencies: [] diff --git a/roles/bookstack/tasks/main.yml b/roles/bookstack/tasks/main.yml new file mode 100644 index 0000000..1ea325b --- /dev/null +++ b/roles/bookstack/tasks/main.yml @@ -0,0 +1,223 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for bookstack + +# ===================================================================== +# 1. VALIDATE REQUIRED SECRETS +# ===================================================================== + +- name: Assert required secrets are set + ansible.builtin.assert: + that: + - bookstack_db_root_password | length > 0 + - bookstack_db_password | length > 0 + - bookstack_admin_password | length > 0 + - (not bookstack_oidc_enabled) or (bookstack_oidc_client_id | length > 0) + - (not bookstack_oidc_enabled) or (bookstack_oidc_client_secret | length > 0) + - (not bookstack_oidc_enabled) or (bookstack_entra_tenant_id | length > 0) + fail_msg: >- + One or more required secrets are unset. Provide them via OpenBao + lookup, Ansible Vault or --extra-vars. See README for the full list. + quiet: true + +# ===================================================================== +# 2. PREPARATION: Packages, directories, APP_KEY +# ===================================================================== + +- name: Ensure required packages are installed + ansible.builtin.package: + name: + - python3-docker + - python3-requests + state: present + +- name: Create docker compose directory + ansible.builtin.file: + path: "{{ bookstack_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create BookStack data directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ bookstack_puid }}" + group: "{{ bookstack_pgid }}" + mode: '0755' + loop: + - "{{ bookstack_docker_volume_dir }}" + - "{{ bookstack_appdata_dir }}" + - "{{ bookstack_db_data_dir }}" + - "{{ bookstack_backup_dir }}" + +- name: Verify Traefik network exists + community.docker.docker_network_info: + name: "{{ bookstack_traefik_network }}" + register: _traefik_net + failed_when: not _traefik_net.exists + +- name: Check whether APP_KEY has been generated before + ansible.builtin.stat: + path: "{{ bookstack_docker_volume_dir }}/.app_key" + register: _app_key_file + +- name: Generate persistent APP_KEY on first run + ansible.builtin.shell: | + set -o pipefail + umask 077 + echo "base64:$(openssl rand -base64 32)" > {{ bookstack_docker_volume_dir }}/.app_key + args: + executable: /bin/bash + creates: "{{ bookstack_docker_volume_dir }}/.app_key" + when: + - not _app_key_file.stat.exists + - bookstack_app_key | length == 0 + +- name: Write inventory-provided APP_KEY + ansible.builtin.copy: + content: "{{ bookstack_app_key }}\n" + dest: "{{ bookstack_docker_volume_dir }}/.app_key" + mode: '0600' + when: + - not _app_key_file.stat.exists + - bookstack_app_key | length > 0 + no_log: true + +- name: Read APP_KEY back into a fact + ansible.builtin.slurp: + src: "{{ bookstack_docker_volume_dir }}/.app_key" + register: _app_key_slurp + no_log: true + +- name: Register APP_KEY fact + ansible.builtin.set_fact: + bookstack_resolved_app_key: "{{ _app_key_slurp.content | b64decode | trim }}" + no_log: true + +# ===================================================================== +# 3. DEPLOY: Render compose, bring stack up +# ===================================================================== + +- name: Render docker-compose.yml for BookStack + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ bookstack_docker_compose_dir }}/docker-compose.yml" + mode: '0640' + notify: restart bookstack + +- name: Start BookStack containers + community.docker.docker_compose_v2: + project_src: "{{ bookstack_docker_compose_dir }}" + state: present + pull: always + wait: true + +# ===================================================================== +# 4. CONFIGURE: Wait for app and seed initial admin user +# ===================================================================== + +- name: Wait for BookStack to be ready + ansible.builtin.command: + cmd: docker exec {{ bookstack_service_name }} curl -sf -o /dev/null -w "%{http_code}" http://localhost/login + register: _bookstack_health + retries: 30 + delay: 5 + until: _bookstack_health.stdout == "200" + changed_when: false + +- name: Wait for BookStack migrations to be complete + community.docker.docker_container_exec: + container: "{{ bookstack_service_name }}-db" + argv: + - mariadb + - --protocol=tcp + - -h + - 127.0.0.1 + - -u + - "{{ bookstack_db_user }}" + - "-p{{ bookstack_db_password }}" + - "{{ bookstack_db_name }}" + - -Nse + - "SHOW TABLES LIKE 'users';" + register: _users_table + retries: 30 + delay: 5 + until: _users_table.stdout | trim == 'users' + changed_when: false + no_log: true + +- name: Check whether the initial admin already exists + community.docker.docker_container_exec: + container: "{{ bookstack_service_name }}-db" + argv: + - mariadb + - --protocol=tcp + - -h + - 127.0.0.1 + - -u + - "{{ bookstack_db_user }}" + - "-p{{ bookstack_db_password }}" + - "{{ bookstack_db_name }}" + - -Nse + - "SELECT COUNT(*) FROM users WHERE email = '{{ bookstack_admin_email }}';" + register: _admin_exists + changed_when: false + no_log: true + +- name: Create initial admin user + community.docker.docker_container_exec: + container: "{{ bookstack_service_name }}" + argv: + - php + - "{{ bookstack_artisan_path }}" + - bookstack:create-admin + - "--email={{ bookstack_admin_email }}" + - "--name={{ bookstack_admin_name }}" + - "--password={{ bookstack_admin_password }}" + when: (_admin_exists.stdout | trim | int) == 0 + no_log: true + +# ===================================================================== +# 5. BACKUP: systemd timer for daily DB + uploads dump +# ===================================================================== + +- name: Render backup script + ansible.builtin.template: + src: backup.sh.j2 + dest: /usr/local/bin/bookstack-backup.sh + owner: root + group: root + mode: '0750' + when: bookstack_backup_enabled | bool + +- name: Render backup systemd service + ansible.builtin.template: + src: bookstack-backup.service.j2 + dest: /etc/systemd/system/bookstack-backup.service + mode: '0644' + when: bookstack_backup_enabled | bool + notify: reload systemd + +- name: Render backup systemd timer + ansible.builtin.template: + src: bookstack-backup.timer.j2 + dest: /etc/systemd/system/bookstack-backup.timer + mode: '0644' + when: bookstack_backup_enabled | bool + notify: reload systemd + +- name: Enable and start backup timer + ansible.builtin.systemd: + name: bookstack-backup.timer + enabled: true + state: started + daemon_reload: true + when: bookstack_backup_enabled | bool + +- name: Disable backup timer when feature is off + ansible.builtin.systemd: + name: bookstack-backup.timer + enabled: false + state: stopped + when: not (bookstack_backup_enabled | bool) + failed_when: false diff --git a/roles/bookstack/templates/backup.sh.j2 b/roles/bookstack/templates/backup.sh.j2 new file mode 100644 index 0000000..65217c2 --- /dev/null +++ b/roles/bookstack/templates/backup.sh.j2 @@ -0,0 +1,41 @@ +#!/bin/bash +# {{ ansible_managed }} +set -euo pipefail + +BACKUP_DIR="{{ bookstack_backup_dir }}" +RETENTION_DAYS={{ bookstack_backup_retention_days }} +APPDATA_DIR="{{ bookstack_appdata_dir }}" +STAMP="$(date +%Y%m%d-%H%M%S)" + +mkdir -p "$BACKUP_DIR" + +# --- DB dump (mariadb-dump from inside the DB container) --- +# Use the app user via TCP because root@localhost is unix_socket-auth only +# in the LSIO MariaDB image and root@% does not exist. +docker exec {{ bookstack_service_name }}-db \ + mariadb-dump \ + --protocol=tcp -h 127.0.0.1 \ + -u "{{ bookstack_db_user }}" -p"{{ bookstack_db_password }}" \ + --single-transaction --routines --triggers --quick \ + "{{ bookstack_db_name }}" \ + | gzip -9 > "$BACKUP_DIR/bookstack-db-$STAMP.sql.gz" + +# --- File uploads (images, attachments) --- +# LSIO BookStack stores user uploads under /config/www/{uploads,storage/uploads,files}. +tar --warning=no-file-changed \ + -czf "$BACKUP_DIR/bookstack-files-$STAMP.tar.gz" \ + -C "$APPDATA_DIR/www" \ + uploads storage/uploads files 2>/dev/null || true + +# --- APP_KEY backup (critical for restore!) --- +install -m 0600 "{{ bookstack_docker_volume_dir }}/.app_key" \ + "$BACKUP_DIR/bookstack-appkey-$STAMP.txt" + +# --- Retention --- +find "$BACKUP_DIR" -type f \ + \( -name 'bookstack-db-*.sql.gz' \ + -o -name 'bookstack-files-*.tar.gz' \ + -o -name 'bookstack-appkey-*.txt' \) \ + -mtime +"$RETENTION_DAYS" -delete + +echo "Backup complete: $STAMP" \ No newline at end of file diff --git a/roles/bookstack/templates/bookstack-backup.service.j2 b/roles/bookstack/templates/bookstack-backup.service.j2 new file mode 100644 index 0000000..cb63795 --- /dev/null +++ b/roles/bookstack/templates/bookstack-backup.service.j2 @@ -0,0 +1,12 @@ +# {{ ansible_managed }} +[Unit] +Description=BookStack backup (DB + uploads) +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/bookstack-backup.sh +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 diff --git a/roles/bookstack/templates/bookstack-backup.timer.j2 b/roles/bookstack/templates/bookstack-backup.timer.j2 new file mode 100644 index 0000000..e13238d --- /dev/null +++ b/roles/bookstack/templates/bookstack-backup.timer.j2 @@ -0,0 +1,11 @@ +# {{ ansible_managed }} +[Unit] +Description=Daily BookStack backup + +[Timer] +OnCalendar={{ bookstack_backup_schedule }} +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target diff --git a/roles/bookstack/templates/docker-compose.yml.j2 b/roles/bookstack/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..863e316 --- /dev/null +++ b/roles/bookstack/templates/docker-compose.yml.j2 @@ -0,0 +1,87 @@ +#---------------------------------------------------------------------# +# BookStack - Self-hosted wiki / knowledge base. # +#---------------------------------------------------------------------# +--- +services: + {{ bookstack_service_name }}: + image: {{ bookstack_image }} + container_name: {{ bookstack_service_name }} + restart: unless-stopped + environment: + PUID: "{{ bookstack_puid }}" + PGID: "{{ bookstack_pgid }}" + TZ: "{{ bookstack_tz }}" + APP_URL: "{{ bookstack_base_url }}" + APP_KEY: "{{ bookstack_resolved_app_key }}" + DB_HOST: "{{ bookstack_service_name }}-db" + DB_PORT: "3306" + DB_DATABASE: "{{ bookstack_db_name }}" + DB_USERNAME: "{{ bookstack_db_user }}" + DB_PASSWORD: "{{ bookstack_db_password }}" + MAIL_DRIVER: "{{ bookstack_mail_driver }}" + MAIL_HOST: "{{ bookstack_mail_host }}" + MAIL_PORT: "{{ bookstack_mail_port }}" + MAIL_USERNAME: "{{ bookstack_mail_username }}" + MAIL_PASSWORD: "{{ bookstack_mail_password }}" + MAIL_ENCRYPTION: "{{ bookstack_mail_encryption }}" + MAIL_FROM: "{{ bookstack_mail_from }}" + MAIL_FROM_NAME: "{{ bookstack_mail_from_name }}" +{% if bookstack_oidc_enabled %} + AUTH_METHOD: "oidc" + AUTH_AUTO_INITIATE: "{{ bookstack_oidc_auto_initiate | string | lower }}" + OIDC_NAME: "{{ bookstack_oidc_name }}" + OIDC_DISPLAY_NAME_CLAIMS: "name" + OIDC_CLIENT_ID: "{{ bookstack_oidc_client_id }}" + OIDC_CLIENT_SECRET: "{{ bookstack_oidc_client_secret }}" + OIDC_ISSUER: "{{ bookstack_oidc_issuer }}" + OIDC_ISSUER_DISCOVER: "true" + OIDC_END_SESSION_ENDPOINT: "true" + OIDC_ADDITIONAL_SCOPES: "{{ bookstack_oidc_additional_scopes }}" + OIDC_USER_TO_GROUPS: "{{ bookstack_oidc_user_to_groups | string | lower }}" + OIDC_GROUPS_CLAIM: "{{ bookstack_oidc_groups_claim }}" +{% endif %} + volumes: + - {{ bookstack_appdata_dir }}:/config + networks: + - {{ bookstack_traefik_network }} + - internal + depends_on: + {{ bookstack_service_name }}-db: + condition: service_healthy + labels: + - "traefik.enable=true" + - "traefik.docker.network={{ bookstack_traefik_network }}" + - "traefik.http.routers.{{ bookstack_service_name }}.rule=Host(`{{ bookstack_domain }}`)" + - "traefik.http.routers.{{ bookstack_service_name }}.entrypoints=websecure" + - "traefik.http.routers.{{ bookstack_service_name }}.tls=true" + - "traefik.http.routers.{{ bookstack_service_name }}.tls.certresolver={{ bookstack_traefik_certresolver }}" + - "traefik.http.services.{{ bookstack_service_name }}.loadbalancer.server.port=80" + + {{ bookstack_service_name }}-db: + image: {{ bookstack_db_image }} + container_name: {{ bookstack_service_name }}-db + restart: unless-stopped + environment: + PUID: "{{ bookstack_puid }}" + PGID: "{{ bookstack_pgid }}" + TZ: "{{ bookstack_tz }}" + MYSQL_ROOT_PASSWORD: "{{ bookstack_db_root_password }}" + MYSQL_DATABASE: "{{ bookstack_db_name }}" + MYSQL_USER: "{{ bookstack_db_user }}" + MYSQL_PASSWORD: "{{ bookstack_db_password }}" + volumes: + - {{ bookstack_db_data_dir }}:/config + networks: + - internal + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u root --password=\"$$MYSQL_ROOT_PASSWORD\" --silent"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + +networks: + {{ bookstack_traefik_network }}: + external: true + internal: + driver: bridge diff --git a/roles/bookstack/tests/inventory b/roles/bookstack/tests/inventory new file mode 100644 index 0000000..2fbb50c --- /dev/null +++ b/roles/bookstack/tests/inventory @@ -0,0 +1 @@ +localhost diff --git a/roles/bookstack/tests/test.yml b/roles/bookstack/tests/test.yml new file mode 100644 index 0000000..15b9be3 --- /dev/null +++ b/roles/bookstack/tests/test.yml @@ -0,0 +1,5 @@ +--- +- hosts: localhost + remote_user: root + roles: + - bookstack diff --git a/roles/bookstack/vars/main.yml b/roles/bookstack/vars/main.yml new file mode 100644 index 0000000..e04b89a --- /dev/null +++ b/roles/bookstack/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for bookstack diff --git a/roles/coturn/README.md b/roles/coturn/README.md new file mode 100644 index 0000000..13d1c3e --- /dev/null +++ b/roles/coturn/README.md @@ -0,0 +1,69 @@ +# coturn + +Deploys a [coturn](https://github.com/coturn/coturn) TURN/STUN server with `network_mode: host`, +optionally accompanied by an `acme.sh` sidecar that obtains and renews a public TLS certificate +via RFC2136 (`nsupdate`) and restarts coturn on renewal. + +This is the recommended pairing for `digitalboard.core.talk` (Nextcloud Talk HPB). + +## What it does + +- Renders `/etc/docker/compose/coturn/docker-compose.yml` +- (acme mode) Deploys the TSIG key from `playbooks/secrets/{{ inventory_hostname }}/nsupdate.key` +- (selfsigned mode) Generates an ECC keypair + selfsigned cert in `{{ coturn_cert_dir }}` +- Starts the stack via `community.docker.docker_compose_v2` + +## Required variables + +| Variable | Description | +|---|---| +| `coturn_realm` | Public DNS name used as realm + cert CN (e.g. `stun.digitalboard.ch`) | +| `coturn_external_ip` | Mapping for `--external-ip`, format `PUBLIC[/PRIVATE]` | +| `coturn_static_auth_secret` | Shared secret for HMAC-based credentials; **must match** `talk_turn_secret` on the HPB host | + +## Important variables + +| Variable | Default | Description | +|---|---|---| +| `coturn_cert_mode` | `file` | One of `acme`, `file`, `selfsigned` | +| `coturn_listening_port` | `443` | TCP/UDP non-TLS port | +| `coturn_tls_listening_port` | `443` | TLS port (shared with non-TLS via STUN mux) | +| `coturn_min_relay_port` / `coturn_max_relay_port` | `49160` / `49200` | UDP relay range | +| `coturn_internal_realm` | `""` | Optional second SAN for split-horizon DNS | +| `coturn_image` | `coturn/coturn:4.6.2-r5-alpine` | Pinned by default; override as needed | + +## ACME / nsupdate mode + +When `coturn_cert_mode: acme` is set, also configure: + +```yaml +coturn_acme_email: "admin@digitalboard.ch" +coturn_acme_nsupdate_server: "ns1.digitalboard.ch" +coturn_acme_nsupdate_server_ip: "172.16.9.169" # optional pin +coturn_acme_nsupdate_zone: "digitalboard._acme.digitalboard.ch" +# optional: override the auto-built challenge alias mapping +coturn_acme_challenge_aliases: + - name: stun.digitalboard.ch + alias: stun.digitalboard._acme.digitalboard.ch + - name: stun.int.digitalboard.ch + alias: stun.int.digitalboard._acme.digitalboard.ch +``` + +Place your TSIG key at `playbooks/secrets/{{ inventory_hostname }}/nsupdate.key` (mode 0600). + +## Secrets + +Place the static auth secret at: + +``` +playbooks/secrets/{{ inventory_hostname }}/coturn_static_auth_secret +``` + +Mode 0600. The same value must be deployed to the HPB host as `talk_turn_secret`. + +## Firewall + +The role does not manage firewall rules. Ensure the host has: + +- `443/tcp` and `443/udp` reachable from the internet +- UDP `{{ coturn_min_relay_port }}-{{ coturn_max_relay_port }}` reachable from the internet diff --git a/roles/coturn/defaults/main.yml b/roles/coturn/defaults/main.yml new file mode 100644 index 0000000..580d9da --- /dev/null +++ b/roles/coturn/defaults/main.yml @@ -0,0 +1,77 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for coturn + +# Base directories (inherited from base role) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# Service-specific paths +coturn_service_name: coturn +coturn_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ coturn_service_name }}" +coturn_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ coturn_service_name }}" + +# Container images (pin per host_vars in production) +coturn_image: "coturn/coturn:4.6.2-r5-alpine" +coturn_acme_image: "neilpang/acme.sh:3.1.0" + +# Public DNS name used for the realm and the public certificate +coturn_realm: "stun.example.test" +# Optional second DNS name issued on the same certificate (for split-horizon "internal" name) +coturn_internal_realm: "" # e.g. "stun.int.example.test" + +# Ports +# Defaults follow IANA standards (3478/TURN, 5349/TURNS) so coturn can +# co-exist with a Traefik instance on the same host. Override to 443/443 +# in restrictive-network environments where punching through firewalls matters. +coturn_listening_port: 3478 # TURN / STUN (TCP+UDP) +coturn_tls_listening_port: 5349 # TURNS (TCP+UDP) +coturn_min_relay_port: 49160 +coturn_max_relay_port: 49200 + +# IP advertisement: must be set in host_vars for production +# Format follows coturn's --external-ip: "PUBLIC_IP" or "PUBLIC_IP/PRIVATE_IP" +coturn_external_ip: "" # e.g. "203.0.113.10/172.18.0.2" +coturn_listening_ip: "0.0.0.0" + +# Shared secret used by HPB to mint short-lived TURN credentials. +# Loaded by default from a plain file in playbooks/secrets/{host}/coturn_static_auth_secret +# Override per host_vars if you want to use a vault or different lookup. +coturn_static_auth_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/coturn_static_auth_secret') }}" + +# Additional CLI flags (list of strings, appended verbatim to command:) +coturn_extra_args: [] + +# --- TLS certificate --- +# 'acme' : run an acme.sh sidecar that issues + renews via RFC2136 / nsupdate, restarts coturn +# 'file' : assume a certificate already lives at {{ coturn_cert_dir }}/{{ coturn_cert_file }} on the host (you manage it) +# 'selfsigned' : generate a selfsigned cert on first run (for vagrant/dev only) +coturn_cert_mode: "file" + +coturn_cert_dir: "{{ docker_volume_base_dir }}/acme/certs" +coturn_cert_file: "fullchain.cer" +coturn_key_file: "{{ coturn_realm }}.key" + +# --- acme.sh sidecar (only used when coturn_cert_mode == 'acme') --- +coturn_acme_email: "admin@example.test" +coturn_acme_directory: "https://acme-v02.api.letsencrypt.org/directory" +# Stage URL for testing: "https://acme-staging-v02.api.letsencrypt.org/directory" +coturn_acme_keylength: "ec-256" +coturn_acme_dnssleep: 60 +coturn_acme_data_dir: "{{ docker_volume_base_dir }}/acme/acme" + +# DNS-01 RFC2136 / nsupdate configuration +coturn_acme_nsupdate_server: "" # e.g. "ns1.example.test" +coturn_acme_nsupdate_server_ip: "" # optional extra_hosts pin (string IP) for the server +coturn_acme_nsupdate_zone: "" # e.g. "example._acme.example.test" +# Per-name challenge alias zones (one entry per SAN) +# When empty (default), built automatically as "{{ realm }}._acme.{{ zone-tail }}" +coturn_acme_challenge_aliases: [] +# Example: +# - name: stun.example.test +# alias: stun.example._acme.example.test +# - name: stun.int.example.test +# alias: stun.int.example._acme.example.test + +# Path of the TSIG key file inside the container (mounted from secrets) +coturn_acme_nsupdate_key_src: "{{ playbook_dir }}/secrets/{{ inventory_hostname }}/nsupdate.key" diff --git a/roles/coturn/handlers/main.yml b/roles/coturn/handlers/main.yml new file mode 100644 index 0000000..0abd12f --- /dev/null +++ b/roles/coturn/handlers/main.yml @@ -0,0 +1,10 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for coturn + +- name: Restart coturn container + community.docker.docker_compose_v2: + project_src: "{{ coturn_docker_compose_dir }}" + state: restarted + services: + - coturn diff --git a/roles/coturn/meta/argument_specs.yml b/roles/coturn/meta/argument_specs.yml new file mode 100644 index 0000000..55a9b3e --- /dev/null +++ b/roles/coturn/meta/argument_specs.yml @@ -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//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. diff --git a/roles/coturn/meta/main.yml b/roles/coturn/meta/main.yml new file mode 100644 index 0000000..68d93a9 --- /dev/null +++ b/roles/coturn/meta/main.yml @@ -0,0 +1,15 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: Digital Board Team + description: Deploy a coturn TURN/STUN server with optional acme.sh sidecar (RFC2136/nsupdate) + company: digitalboard.ch + license: GPL-2.0-or-later + min_ansible_version: "2.14" + galaxy_tags: + - turn + - stun + - coturn + - webrtc + - nextcloud + - talk +dependencies: [] diff --git a/roles/coturn/tasks/main.yml b/roles/coturn/tasks/main.yml new file mode 100644 index 0000000..cf9c15a --- /dev/null +++ b/roles/coturn/tasks/main.yml @@ -0,0 +1,110 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for coturn + +- name: Assert minimum configuration + ansible.builtin.assert: + that: + - coturn_realm | length > 0 + - coturn_external_ip | length > 0 + - coturn_static_auth_secret | length > 0 + fail_msg: > + coturn_realm, coturn_external_ip and coturn_static_auth_secret must be set. + Provide them in host_vars or via a secrets file. + +- name: Create coturn compose directory + ansible.builtin.file: + path: "{{ coturn_docker_compose_dir }}" + state: directory + mode: "0755" + +- name: Create coturn data directory + ansible.builtin.file: + path: "{{ coturn_docker_volume_dir }}" + state: directory + mode: "0755" + +- name: Create certificate directory + ansible.builtin.file: + path: "{{ coturn_cert_dir }}" + state: directory + mode: "0755" + +# --- TLS certificate provisioning ------------------------------------------------- + +- name: Configure acme.sh sidecar (TSIG key + acme data dir) + when: coturn_cert_mode == 'acme' + block: + - name: Create acme.sh data directory + ansible.builtin.file: + path: "{{ coturn_acme_data_dir }}" + state: directory + mode: "0700" + + - name: Deploy nsupdate TSIG key + ansible.builtin.copy: + src: "{{ coturn_acme_nsupdate_key_src }}" + dest: "{{ coturn_docker_compose_dir }}/nsupdate.key" + mode: "0600" + no_log: true + notify: Restart coturn container + + - name: Build effective challenge alias list (default if not provided) + ansible.builtin.set_fact: + _coturn_challenge_aliases: >- + {{ coturn_acme_challenge_aliases + if coturn_acme_challenge_aliases | length > 0 + else ( + [{'name': coturn_realm, + 'alias': (coturn_realm.split('.')[:-2] | join('.')) ~ '.' ~ coturn_acme_nsupdate_zone }] + + ([{'name': coturn_internal_realm, + 'alias': (coturn_internal_realm.split('.')[:-2] | join('.')) ~ '.' ~ coturn_acme_nsupdate_zone }] + if coturn_internal_realm | length > 0 else []) + ) + }} + +- name: Generate selfsigned certificate (vagrant / dev only) + when: coturn_cert_mode == 'selfsigned' + block: + - name: Ensure openssl is available + ansible.builtin.package: + name: openssl + state: present + + - name: Generate selfsigned private key + community.crypto.openssl_privatekey: + path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}" + type: ECC + curve: secp256r1 + mode: "0600" + + - name: Generate selfsigned CSR + community.crypto.openssl_csr: + path: "{{ coturn_cert_dir }}/{{ coturn_realm }}.csr" + privatekey_path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}" + common_name: "{{ coturn_realm }}" + subject_alt_name: + - "DNS:{{ coturn_realm }}" + mode: "0644" + + - name: Issue selfsigned certificate + community.crypto.x509_certificate: + path: "{{ coturn_cert_dir }}/{{ coturn_cert_file }}" + privatekey_path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}" + csr_path: "{{ coturn_cert_dir }}/{{ coturn_realm }}.csr" + provider: selfsigned + mode: "0644" + +# --- Compose + start -------------------------------------------------------------- + +- name: Generate docker-compose.yml for coturn + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ coturn_docker_compose_dir }}/docker-compose.yml" + mode: "0644" + notify: Restart coturn container + +- name: Start coturn stack + community.docker.docker_compose_v2: + project_src: "{{ coturn_docker_compose_dir }}" + state: present diff --git a/roles/coturn/templates/docker-compose.yml.j2 b/roles/coturn/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..42bdcb5 --- /dev/null +++ b/roles/coturn/templates/docker-compose.yml.j2 @@ -0,0 +1,78 @@ +services: + coturn: + image: {{ coturn_image }} + container_name: {{ coturn_service_name }} + restart: always + network_mode: host + volumes: + - {{ coturn_cert_dir }}:/certs:ro + command: + - --use-auth-secret + - --static-auth-secret={{ coturn_static_auth_secret }} + - --realm={{ coturn_realm }} + - --fingerprint + - --no-multicast-peers + - --no-cli + - --listening-ip={{ coturn_listening_ip }} + - --listening-port={{ coturn_listening_port }} + - --tls-listening-port={{ coturn_tls_listening_port }} + - --min-port={{ coturn_min_relay_port }} + - --max-port={{ coturn_max_relay_port }} + - --cert=/certs/{{ coturn_cert_file }} + - --pkey=/certs/{{ coturn_key_file }} + - --external-ip={{ coturn_external_ip }} +{% for arg in coturn_extra_args %} + - {{ arg }} +{% endfor %} + +{% if coturn_cert_mode == 'acme' %} + acme: + image: {{ coturn_acme_image }} + container_name: acme-{{ coturn_service_name }} + restart: always + environment: + NSUPDATE_SERVER: "{{ coturn_acme_nsupdate_server }}" + NSUPDATE_KEY: "/acme.sh/nsupdate.key" + ACME_DIRECTORY: "{{ coturn_acme_directory }}" + NSUPDATE_ZONE: "{{ coturn_acme_nsupdate_zone }}" +{% if coturn_acme_nsupdate_server_ip | length > 0 %} + extra_hosts: + - "{{ coturn_acme_nsupdate_server }}:{{ coturn_acme_nsupdate_server_ip }}" +{% endif %} + volumes: + - {{ coturn_cert_dir }}:/certs + - /var/run/docker.sock:/var/run/docker.sock + - {{ coturn_docker_compose_dir }}/nsupdate.key:/acme.sh/nsupdate.key:ro + - {{ coturn_acme_data_dir }}:/acme.sh + entrypoint: + - /bin/sh + - -c + - | + set -eu + acme.sh --set-default-ca --server "$$ACME_DIRECTORY" + acme.sh --register-account -m {{ coturn_acme_email }} || true + set +e + acme.sh --issue \ +{% for san in _coturn_challenge_aliases %} + -d {{ san.name }} \ + --challenge-alias {{ san.alias }} \ +{% endfor %} + --dns dns_nsupdate \ + --keylength {{ coturn_acme_keylength }} \ + --dnssleep {{ coturn_acme_dnssleep }} + rc=$$? + set -e + if [ "$$rc" -eq 0 ]; then + echo "Issue: success" + elif [ "$$rc" -eq 2 ]; then + echo "Issue: not due, continuing" + else + echo "Issue: failed with rc=$$rc" + exit "$$rc" + fi + acme.sh --install-cert -d {{ coturn_realm }} --ecc \ + --fullchain-file /certs/{{ coturn_cert_file }} \ + --key-file /certs/{{ coturn_key_file }} \ + --reloadcmd 'curl --fail --silent --show-error --unix-socket /var/run/docker.sock -X POST http://localhost/v1.41/containers/{{ coturn_service_name }}/restart' || true + exec crond -f +{% endif %} diff --git a/roles/coturn/tests/inventory b/roles/coturn/tests/inventory new file mode 100644 index 0000000..eec845d --- /dev/null +++ b/roles/coturn/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost \ No newline at end of file diff --git a/roles/coturn/tests/tests.yml b/roles/coturn/tests/tests.yml new file mode 100644 index 0000000..828e0fb --- /dev/null +++ b/roles/coturn/tests/tests.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - coturn diff --git a/roles/coturn/vars/main.yml b/roles/coturn/vars/main.yml new file mode 100644 index 0000000..f2a4ea3 --- /dev/null +++ b/roles/coturn/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for coturn diff --git a/roles/homarr/filter_plugins/tests/test_homarr_layout.py b/roles/homarr/filter_plugins/tests/test_homarr_layout.py index 3a49f2b..a96d672 100644 --- a/roles/homarr/filter_plugins/tests/test_homarr_layout.py +++ b/roles/homarr/filter_plugins/tests/test_homarr_layout.py @@ -15,7 +15,11 @@ import sys # Make the filter importable without having Ansible auto-discovery in # the way (it would only run during a real `ansible-playbook` invocation). -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert( + 0, + os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', + 'plugins', 'filter') +) import pytest # noqa: E402 diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index 9d00cde..ffb0bb7 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -136,7 +136,7 @@ - name: Compute Homarr app layouts ansible.builtin.set_fact: - homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}" + homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}" - name: Show computed app layouts ansible.builtin.debug: diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index 7535b5a..0c96046 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -61,6 +61,26 @@ nextcloud_trusted_proxies: "172.16.0.0/12" nextcloud_enable_notify_push: false nextcloud_notify_push_image: "icewind1991/notify_push:1.3.1" +# Nextcloud Talk: register external HPB signaling + TURN + STUN +# Set to true to run tasks/talk.yml after Nextcloud is up. +nextcloud_enable_talk: false + +# HPB signaling servers to register. +# Each item: { server: "https://signaling.example.test", secret: "", verify: true } +nextcloud_talk_signaling_servers: [] +# Hostnames/URLs to remove via `talk:signaling:delete` before adding the new set. +nextcloud_talk_signaling_servers_removed: [] + +# TURN servers to register. +# Each item: { server: "stun.example.test:443", secret: "", schemes: "turn,turns", protocols: "udp,tcp" } +nextcloud_talk_turn_servers: [] +# Clear the spreed.turn_servers config key before re-adding (single source of truth) +nextcloud_talk_turn_reset_before_add: true + +# Plain STUN endpoints (host:port). Optional; usually not needed if TURN servers handle STUN too. +nextcloud_talk_stun_servers: [] +nextcloud_talk_stun_servers_removed: [] + # Non-default apps to install and enable nextcloud_apps_to_install: - groupfolders diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index 8d2a5cd..e33088b 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -91,3 +91,7 @@ - name: Configure OIDC providers ansible.builtin.include_tasks: oidc.yml when: nextcloud_oidc_providers | length > 0 or nextcloud_oidc_providers_removed | length > 0 + +- name: Configure Nextcloud Talk (HPB + TURN + STUN) + ansible.builtin.include_tasks: talk.yml + when: nextcloud_enable_talk diff --git a/roles/nextcloud/tasks/talk.yml b/roles/nextcloud/tasks/talk.yml new file mode 100644 index 0000000..aaf67e3 --- /dev/null +++ b/roles/nextcloud/tasks/talk.yml @@ -0,0 +1,70 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for configuring Nextcloud Talk HPB + TURN + STUN registration + +# --- HPB / signaling ----------------------------------------------------------- + +- name: Remove HPB signaling servers no longer in use + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ talk:signaling:delete {{ item }} + loop: "{{ nextcloud_talk_signaling_servers_removed }}" + register: _talk_sig_removed + changed_when: "'deleted' in (_talk_sig_removed.stdout | default(''))" + failed_when: + - _talk_sig_removed.rc != 0 + - "'is not configured' not in (_talk_sig_removed.stderr | default(''))" + +- name: Register HPB signaling servers + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: > + php /var/www/html/occ talk:signaling:add + {{ item.server }} + {{ item.secret }} + {% if item.verify | default(true) %}--verify{% endif %} + loop: "{{ nextcloud_talk_signaling_servers }}" + no_log: true + +# --- TURN ---------------------------------------------------------------------- +# `talk:turn:add` appends without deduplication, so on each run we first clear +# the list via the underlying app config key (turn_servers, JSON array) and +# then re-add the declared set. This keeps the host_vars list as the single +# source of truth. + +- name: Reset TURN server list before re-applying + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ config:app:set spreed turn_servers --value='[]' + when: nextcloud_talk_turn_reset_before_add | bool + +- name: Register TURN servers + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: > + php /var/www/html/occ talk:turn:add + {{ item.schemes | default('turn,turns') }} + {{ item.server }} + {{ item.protocols | default('udp,tcp') }} + --secret={{ item.secret }} + loop: "{{ nextcloud_talk_turn_servers }}" + no_log: true + +# --- STUN ---------------------------------------------------------------------- + +- name: Remove STUN servers no longer in use + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ talk:stun:delete {{ item }} + loop: "{{ nextcloud_talk_stun_servers_removed }}" + register: _talk_stun_removed + changed_when: "'deleted' in (_talk_stun_removed.stdout | default(''))" + failed_when: + - _talk_stun_removed.rc != 0 + - "'is not configured' not in (_talk_stun_removed.stderr | default(''))" + +- name: Register STUN servers + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ talk:stun:add {{ item }} + loop: "{{ nextcloud_talk_stun_servers }}" diff --git a/roles/opnform/README.md b/roles/opnform/README.md new file mode 100644 index 0000000..2dfad2d --- /dev/null +++ b/roles/opnform/README.md @@ -0,0 +1,169 @@ +# opnform + +Deploy [OpnForm](https://github.com/OpnForm/OpnForm) as a self-contained +Docker Compose stack behind Traefik. + +## What this role does + +- Deploys the full official OpnForm stack: `api`, `api-worker`, `api-scheduler`, + `ui`, `db` (Postgres), `redis`, and `ingress` (nginx) +- Configures all environment variables for self-hosted production use +- Integrates the ingress container with an existing Traefik proxy network +- Waits for the API container to become healthy before returning + +## What this role does NOT do (stage 1) + +- Does not pre-configure OIDC / identity_connections — set up via Admin UI + +## Architecture note: why two reverse proxies? + +``` +Browser → Traefik (TLS, host routing) → ingress-nginx → api (PHP-FPM) / ui (Nuxt) +``` + +The `ingress` container looks like a redundant proxy next to Traefik but +does a different job. OpnForm's `api` image is **PHP-FPM only** — it +speaks the FastCGI protocol on port 9000, not HTTP. Traefik cannot +translate FastCGI, so the ingress nginx is required to: + +- Translate HTTP `/api/*` requests into FastCGI calls to `api:9000` +- Rewrite request URIs via the `$api_uri` map +- Set Laravel-specific FastCGI params (`SCRIPT_FILENAME`, `REQUEST_URI`) +- Reverse-proxy `/` to the Nuxt UI container on port 3000 + +Both containers run on the same Docker network on the same host, so the +performance overhead of the extra hop is negligible (in-kernel memory +copy, not a real network round-trip). Removing the ingress would require +a custom OpnForm image with a built-in HTTP server, which is out of +scope for this role. + +## Required variables + +Provide via OpenBao, Ansible Vault, or extra-vars. **Never commit real +secrets to version control.** + +| Variable | Format | Generate with | +|---|---|---| +| `opnform_app_key` | `base64:<32 bytes base64>` | `echo "base64:$(openssl rand -base64 32)"` | +| `opnform_jwt_secret` | 32 bytes base64 | `openssl rand -base64 32` | +| `opnform_front_api_secret` | 32 bytes base64 | `openssl rand -base64 32` | +| `opnform_db_password` | strong password | `openssl rand -base64 24` | + +When `opnform_oidc_enabled` is `true`: + +| Variable | Source | +|---|---| +| `opnform_oidc_client_secret` | from your Keycloak/Authentik client | + +The `assert` task at the top of the role will fail fast if any secret is +missing or malformed. + +## First login + +OpnForm in self-hosted mode does **not** ship a pre-seeded admin user. +The first user to register becomes the owner of the default workspace, +and further public registration is disabled afterwards (additional +users must be invited via the Admin UI). + +This role supports two ways to create that first user: + +### Option A — automated bootstrap (recommended) + +Set `opnform_admin_email` and `opnform_admin_password` (ideally from +Vault / OpenBao). The role then POSTs to `/api/register` after the +API container is healthy, skipping the setup page entirely. The task +is idempotent: it does a login check first and only registers if the +user does not already exist. + +```yaml +opnform_admin_name: "Administrator" # default +opnform_admin_email: "admin@example.com" +opnform_admin_password: "{{ vault_opnform_admin_password }}" +``` + +Password rules enforced by OpnForm: minimum 8 characters, at least one +letter, one digit, and one of `@$!%*#?&-_+=.,:;<>^()[]{}|~`. + +### Option B — manual setup page + +Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit +`opnform_base_url` and complete the setup page in the browser. + +## OIDC setup + +Set `opnform_oidc_enabled: true` and the role creates an +IdentityConnection on the admin's default workspace via +`POST /api/open/workspaces/{id}/oidc-connections`. OpnForm enforces a +single OIDC connection per workspace, so the task is idempotent (GETs +existing connections first and skips if any exist). + +**Prerequisite**: the admin bootstrap must be configured +(`opnform_admin_email` + `opnform_admin_password`). The OIDC API +requires an authenticated admin token; the role logs in with those +credentials to make the call. The validation block fails fast if OIDC +is enabled without admin credentials. + +### Required when `opnform_oidc_enabled: true` + +| Variable | Notes | +|---|---| +| `opnform_oidc_client_secret` | from your IdP, never commit | +| `opnform_oidc_domain` | email domain that triggers OIDC (e.g. `example.com`) | + +### Tunables (defaults shown) + +```yaml +opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard" +opnform_oidc_client_id: "opnform-digitalboard" +opnform_oidc_client_name: "Digitalboard" # display name in UI +opnform_oidc_slug: "oidc" # used in /auth/{slug}/callback +opnform_oidc_scopes: [openid, profile, email, groups] +``` + +### Group → role mapping + +Two ways, the list takes precedence: + +```yaml +# Option 1: full list (any number of mappings) +opnform_oidc_group_role_mappings: + - idp_group: "opnform-admins" + role: admin + - idp_group: "opnform-editors" + role: editor + +# Option 2: convenience — single admin group +opnform_oidc_admin_group: "opnform-admins" # mapped to role=admin +``` + +Valid roles: `owner`, `admin`, `editor`, `member`. + +## Example playbook + +```yaml +- name: Deploy OpnForm service + hosts: opnform_servers + become: true + roles: + - digitalboard.core.opnform +``` + +With inventory variables: + +```yaml +# group_vars/opnform_servers.yml +opnform_domain: forms.digitalboard.ch +opnform_base_url: "https://forms.digitalboard.ch" +opnform_app_key: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.app_key }}" +opnform_jwt_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.jwt_secret }}" +opnform_front_api_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.front_api_secret }}" +opnform_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.db_password }}" +``` diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml new file mode 100644 index 0000000..0f61c3a --- /dev/null +++ b/roles/opnform/defaults/main.yml @@ -0,0 +1,109 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for opnform + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# opnform-specific configuration +opnform_service_name: opnform +opnform_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ opnform_service_name }}" +opnform_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ opnform_service_name }}" +opnform_storage_dir: "{{ opnform_docker_volume_dir }}/storage" +opnform_db_data_dir: "{{ opnform_docker_volume_dir }}/db" +opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis" + +# Service configuration +opnform_domain: "forms.local.test" +opnform_base_url: "https://forms.local.test" + +# Images +opnform_api_image: "jhumanj/opnform-api:latest" +opnform_client_image: "jhumanj/opnform-client:latest" +opnform_redis_image: "redis:7" +opnform_db_image: "postgres:16" +opnform_ingress_image: "nginx:1" + +# REQUIRED SECRETS — must be overridden per-inventory. +# Provide via OpenBao lookup, Ansible Vault or extra-vars. +# Never commit real keys to version control. +# +# Generate with: +# opnform_app_key: echo "base64:$(openssl rand -base64 32)" +# opnform_jwt_secret: openssl rand -hex 32 +# opnform_front_api_secret: openssl rand -hex 32 +# +# opnform_app_key MUST start with the prefix "base64:" — the validate +# task at the top of tasks/main.yml enforces this. +opnform_app_key: "" +opnform_jwt_secret: "" +opnform_front_api_secret: "" + +# Database credentials. opnform_db_password must be overridden; the +# validate task fails fast on an empty value. +opnform_db_name: "opnform" +opnform_db_user: "opnform" +opnform_db_password: "" + +# Admin bootstrap — when email+password are set, the role creates the +# first user via OpnForm's /api/register endpoint, skipping the +# self-hosted setup page. Leave both empty to keep the manual setup flow. +# Password must satisfy OpnForm's rules: min 8 chars, contain a letter, +# a digit and one of @$!%*#?&-_+=.,:;<>^()[]{}|~ +# Provide via OpenBao, Ansible Vault or extra-vars. +opnform_admin_name: "Administrator" +opnform_admin_email: "" +opnform_admin_password: "" +opnform_admin_hear_about_us: "ansible" + +# PHP configuration +opnform_php_memory_limit: "1G" +opnform_php_max_execution_time: "600" +opnform_php_upload_max_filesize: "64M" +opnform_php_post_max_size: "64M" + +# Nginx ingress +opnform_nginx_max_body_size: "64m" + +# Mail configuration (optional — defaults to log driver) +opnform_mail_mailer: "log" +opnform_mail_host: "" +opnform_mail_port: "" +opnform_mail_username: "" +opnform_mail_password: "" +opnform_mail_encryption: "" +opnform_mail_from_address: "noreply@digitalboard.ch" +opnform_mail_from_name: "OpnForm" + +# OIDC configuration — when enabled, the role auto-creates an +# IdentityConnection in the first workspace via OpnForm's API after the +# admin bootstrap. Requires opnform_admin_email/_password to be set +# (the API call needs an authenticated admin token). +opnform_oidc_enabled: false +opnform_oidc_issuer: "https://auth.digitalboard.ch/realms/Digitalboard" +opnform_oidc_client_id: "opnform-digitalboard" +opnform_oidc_client_secret: "" +opnform_oidc_client_name: "Digitalboard" +# OpnForm-side identifier used in /auth/{slug}/callback. Lowercase +# alphanumeric + hyphens, unique across all identity_connections. +opnform_oidc_slug: "oidc" +# Email domain that triggers OIDC login for matching users (e.g. users +# with @example.com emails are redirected to the IdP). Required when +# opnform_oidc_enabled is true. +opnform_oidc_domain: "" +opnform_oidc_scopes: + - openid + - profile + - email + - groups +# Convenience: maps a single IdP group to the OpnForm "admin" role. +# Ignored when opnform_oidc_group_role_mappings is non-empty. +opnform_oidc_admin_group: "opnform-admins" +# Full group-to-role mapping list. Takes precedence over the convenience +# var. Each item: {idp_group: "", role: "owner|admin|editor|member"} +opnform_oidc_group_role_mappings: [] + +# Traefik configuration +opnform_traefik_network: "proxy" +opnform_use_ssl: true diff --git a/roles/opnform/handlers/main.yml b/roles/opnform/handlers/main.yml new file mode 100644 index 0000000..1c0b422 --- /dev/null +++ b/roles/opnform/handlers/main.yml @@ -0,0 +1,8 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for opnform + +- name: restart opnform + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: restarted diff --git a/roles/opnform/meta/argument_specs.yml b/roles/opnform/meta/argument_specs.yml new file mode 100644 index 0000000..9fbfc7a --- /dev/null +++ b/roles/opnform/meta/argument_specs.yml @@ -0,0 +1,220 @@ +--- +argument_specs: + main: + short_description: Deploy OpnForm (api + ui + db + redis + ingress) via Docker Compose. + description: + - Renders a Compose stack for the full OpnForm setup (PHP-FPM api, + Nuxt ui, Postgres, Redis, nginx ingress) and exposes it through + Traefik. + - Optionally bootstraps the first admin user via the OpnForm + C(/api/register) endpoint (skipping the self-hosted setup page) + and provisions a single OIDC identity connection in the default + workspace via the workspace API. Both bootstraps are idempotent. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + opnform_service_name: + type: str + default: opnform + opnform_docker_compose_dir: + type: path + description: Defaults to C({{ docker_compose_base_dir }}/{{ opnform_service_name }}). + opnform_docker_volume_dir: + type: path + description: Defaults to C({{ docker_volume_base_dir }}/{{ opnform_service_name }}). + opnform_storage_dir: + type: path + description: OpnForm storage volume mounted into the api container. + opnform_db_data_dir: + type: path + opnform_redis_data_dir: + type: path + + opnform_domain: + type: str + default: forms.local.test + description: Hostname used in the traefik Host rule. + opnform_base_url: + type: str + default: https://forms.local.test + description: Public URL OpnForm uses for APP_URL and NUXT_PUBLIC_APP_URL. + + opnform_api_image: + type: str + default: jhumanj/opnform-api:latest + opnform_client_image: + type: str + default: jhumanj/opnform-client:latest + opnform_redis_image: + type: str + default: "redis:7" + opnform_db_image: + type: str + default: "postgres:16" + opnform_ingress_image: + type: str + default: "nginx:1" + + opnform_app_key: + type: str + required: true + description: + - Laravel application key. Must be prefixed with C(base64:). + Generate with C(echo "base64:$(openssl rand -base64 32)"). + Provide via OpenBao, Ansible Vault or extra-vars. + opnform_jwt_secret: + type: str + required: true + description: JWT signing secret. Generate with C(openssl rand -hex 32). + opnform_front_api_secret: + type: str + required: true + description: Shared secret between ui and api. Generate with C(openssl rand -hex 32). + + opnform_db_name: + type: str + default: opnform + opnform_db_user: + type: str + default: opnform + opnform_db_password: + type: str + required: true + + opnform_admin_name: + type: str + default: Administrator + opnform_admin_email: + type: str + default: '' + description: + - When non-empty (together with C(opnform_admin_password)) the role + bootstraps the first user via C(/api/register), skipping the + self-hosted setup page. Required when C(opnform_oidc_enabled=true). + opnform_admin_password: + type: str + default: '' + description: + - "Must satisfy OpnForm's policy: min 8 chars, letter + digit + + symbol from C(@$!%*#?&-_+=.,:;<>^()[]{}|~)." + opnform_admin_hear_about_us: + type: str + default: ansible + + opnform_php_memory_limit: + type: str + default: 1G + opnform_php_max_execution_time: + type: str + default: "600" + opnform_php_upload_max_filesize: + type: str + default: 64M + opnform_php_post_max_size: + type: str + default: 64M + opnform_nginx_max_body_size: + type: str + default: 64m + + opnform_mail_mailer: + type: str + default: log + choices: [log, smtp, ses, mailgun, postmark, sendmail] + opnform_mail_host: + type: str + default: '' + opnform_mail_port: + type: str + default: '' + opnform_mail_username: + type: str + default: '' + opnform_mail_password: + type: str + default: '' + opnform_mail_encryption: + type: str + default: '' + choices: ['', tls, ssl] + opnform_mail_from_address: + type: str + default: noreply@digitalboard.ch + opnform_mail_from_name: + type: str + default: OpnForm + + opnform_oidc_enabled: + type: bool + default: false + description: + - "When true the role calls the workspace API to create a single + OIDC C(identity_connection) on the default workspace after the + admin bootstrap. Requires C(opnform_admin_email) + + C(opnform_admin_password) so the role can authenticate. + Idempotent: skipped when any connection already exists." + opnform_oidc_issuer: + type: str + default: https://auth.digitalboard.ch/realms/Digitalboard + description: OIDC issuer URL. + opnform_oidc_client_id: + type: str + default: opnform-digitalboard + opnform_oidc_client_secret: + type: str + default: '' + description: Required when C(opnform_oidc_enabled=true). + opnform_oidc_client_name: + type: str + default: Digitalboard + description: Display name shown in the OpnForm UI. + opnform_oidc_slug: + type: str + default: oidc + description: + - OpnForm-side identifier used in C(/auth/{slug}/callback). Lowercase + alphanumeric + hyphens, unique across all C(identity_connections). + opnform_oidc_domain: + type: str + default: '' + description: + - Email domain that triggers OIDC for matching users. Required + when C(opnform_oidc_enabled=true). + opnform_oidc_scopes: + type: list + elements: str + default: [openid, profile, email, groups] + opnform_oidc_admin_group: + type: str + default: opnform-admins + description: + - Convenience setting that maps a single IdP group to the OpnForm + C(admin) role. Ignored when C(opnform_oidc_group_role_mappings) + is non-empty. + opnform_oidc_group_role_mappings: + type: list + elements: dict + default: [] + description: + - Full IdP-group -> OpnForm-role mapping. Takes precedence over + C(opnform_oidc_admin_group). + options: + idp_group: + type: str + required: true + description: Group name as it appears in the IdP groups claim. + role: + type: str + required: true + choices: [owner, admin, editor, member] + + opnform_traefik_network: + type: str + default: proxy + opnform_use_ssl: + type: bool + default: true diff --git a/roles/opnform/meta/main.yml b/roles/opnform/meta/main.yml new file mode 100644 index 0000000..8a56a7b --- /dev/null +++ b/roles/opnform/meta/main.yml @@ -0,0 +1,16 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: Tobias Wüst + description: Deploy OpnForm self-hosted form builder via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 + min_ansible_version: "2.15" + + galaxy_tags: + - opnform + - forms + - docker + - traefik + - oidc + +dependencies: [] diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml new file mode 100644 index 0000000..68e093b --- /dev/null +++ b/roles/opnform/tasks/main.yml @@ -0,0 +1,265 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for opnform + +# ===================================================================== +# 0. VALIDATION +# ===================================================================== + +- name: Validate required secrets + ansible.builtin.assert: + that: + - opnform_app_key | length > 0 + - opnform_app_key is match('^base64:[A-Za-z0-9+/=]+$') + - opnform_jwt_secret | length > 0 + - opnform_front_api_secret | length > 0 + - opnform_db_password | length > 0 + fail_msg: >- + OpnForm requires opnform_app_key (prefix 'base64:'), opnform_jwt_secret, + opnform_front_api_secret and opnform_db_password. + Generate with: openssl rand -base64 32 + The app_key MUST be prefixed with "base64:" + Provide via OpenBao, Ansible Vault or extra-vars. + success_msg: Secrets validation passed + +- name: Validate OIDC configuration when enabled + ansible.builtin.assert: + that: + - opnform_oidc_client_secret | length > 0 + - opnform_oidc_domain | length > 0 + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + fail_msg: >- + When opnform_oidc_enabled is true, you must set: + - opnform_oidc_client_secret + - opnform_oidc_domain (email domain that triggers OIDC) + - opnform_admin_email / opnform_admin_password + (the OIDC API requires an authenticated admin; the role logs in + with these credentials to POST the connection) + when: opnform_oidc_enabled | bool + +# ===================================================================== +# 1. PREPARATION +# ===================================================================== + +- name: Ensure required packages are installed + ansible.builtin.package: + name: + - python3-docker + state: present + +- name: Create docker compose directory + ansible.builtin.file: + path: "{{ opnform_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create OpnForm data directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ opnform_docker_volume_dir }}" + - "{{ opnform_storage_dir }}" + - "{{ opnform_db_data_dir }}" + - "{{ opnform_redis_data_dir }}" + +# ===================================================================== +# 2. CONFIGURATION FILES +# ===================================================================== + +- name: Deploy nginx ingress configuration + ansible.builtin.template: + src: nginx.conf.j2 + dest: "{{ opnform_docker_compose_dir }}/nginx.conf" + mode: '0644' + notify: restart opnform + +- name: Deploy docker-compose file + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + notify: restart opnform + +# ===================================================================== +# 3. CONTAINER STARTUP +# ===================================================================== + +- name: Start opnform containers + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: present + wait: true + wait_timeout: 180 + +# ===================================================================== +# 4. WAIT FOR API READINESS +# ===================================================================== + +- name: Wait for API container to be healthy + ansible.builtin.command: + cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api + register: api_health + until: api_health.stdout == "healthy" + retries: 30 + delay: 10 + changed_when: false + +# ===================================================================== +# 5. ADMIN BOOTSTRAP (optional) +# ===================================================================== +# Skips the self-hosted setup page by registering the first user via +# OpnForm's /api/register endpoint. Idempotent: a successful login +# attempt with the same credentials means the user already exists. + +- name: Check if OpnForm admin user already exists + ansible.builtin.uri: + url: "https://127.0.0.1/api/login" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + status_code: [200, 401, 422] + validate_certs: false + register: opnform_admin_login + when: + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + +- name: Create OpnForm admin user via /api/register + ansible.builtin.uri: + url: "https://127.0.0.1/api/register" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + name: "{{ opnform_admin_name }}" + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + password_confirmation: "{{ opnform_admin_password }}" + hear_about_us: "{{ opnform_admin_hear_about_us }}" + status_code: [200, 201] + validate_certs: false + no_log: true + when: + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + - opnform_admin_login.status != 200 + +# ===================================================================== +# 6. OIDC IDENTITY CONNECTION (optional) +# ===================================================================== +# Creates a single OIDC connection on the admin's default workspace. +# OpnForm enforces one OIDC connection per workspace, so this block is +# idempotent: we GET existing connections first and skip if any exists. + +- name: Log in as admin to obtain OIDC API token + ansible.builtin.uri: + url: "https://127.0.0.1/api/login" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + status_code: 200 + validate_certs: false + register: opnform_oidc_token + no_log: true + when: opnform_oidc_enabled | bool + +- name: Fetch admin's workspaces + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces" + method: GET + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + status_code: 200 + validate_certs: false + register: opnform_workspaces + no_log: true + when: opnform_oidc_enabled | bool + +- name: Fetch existing OIDC connections for the default workspace + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" + method: GET + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + status_code: 200 + validate_certs: false + register: opnform_existing_oidc + no_log: true + when: opnform_oidc_enabled | bool + +- name: Resolve OIDC group-role mappings + ansible.builtin.set_fact: + _opnform_oidc_group_role_mappings: >- + {{ + opnform_oidc_group_role_mappings + if (opnform_oidc_group_role_mappings | length > 0) + else + ([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}] + if (opnform_oidc_admin_group | length > 0) else []) + }} + when: opnform_oidc_enabled | bool + +- name: Create OIDC identity connection + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" + method: POST + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + body_format: json + body: + name: "{{ opnform_oidc_client_name }}" + slug: "{{ opnform_oidc_slug }}" + domain: "{{ opnform_oidc_domain }}" + issuer: "{{ opnform_oidc_issuer }}" + client_id: "{{ opnform_oidc_client_id }}" + client_secret: "{{ opnform_oidc_client_secret }}" + scopes: "{{ opnform_oidc_scopes }}" + enabled: true + options: + require_state: true + group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}" + status_code: [201] + validate_certs: false + no_log: true + when: + - opnform_oidc_enabled | bool + - opnform_existing_oidc.json | length == 0 + +- name: Display deployment info + ansible.builtin.debug: + msg: |- + OpnForm deployed at {{ opnform_base_url }} + + {% if opnform_admin_email | length > 0 %} + Admin user bootstrapped: + Email: {{ opnform_admin_email }} + Password: (from opnform_admin_password) + {% else %} + No admin bootstrap configured — visit {{ opnform_base_url }} and + complete the self-hosted setup page to create the first user. + Set opnform_admin_email + opnform_admin_password to automate this. + {% endif %} + + {% if opnform_oidc_enabled %} + OIDC: connection "{{ opnform_oidc_client_name }}" bootstrapped + (slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }}) + Users with @{{ opnform_oidc_domain }} addresses will be + redirected to {{ opnform_oidc_issuer }} on login. + {% else %} + OIDC: disabled (set opnform_oidc_enabled=true to auto-configure) + {% endif %} diff --git a/roles/opnform/templates/docker-compose.yml.j2 b/roles/opnform/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..de88a33 --- /dev/null +++ b/roles/opnform/templates/docker-compose.yml.j2 @@ -0,0 +1,189 @@ +#---------------------------------------------------------------------# +# OpnForm — Beautiful open-source form builder # +#---------------------------------------------------------------------# +services: + api: &api-service + image: {{ opnform_api_image }} + container_name: opnform-api + restart: unless-stopped + volumes: + - {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw + environment: &api-env + APP_ENV: production + APP_KEY: "{{ opnform_app_key }}" + APP_URL: "{{ opnform_base_url }}" + APP_DEBUG: "false" + SELF_HOSTED: "true" + + LOG_CHANNEL: errorlog + LOG_LEVEL: info + + DB_CONNECTION: pgsql + DB_HOST: db + DB_PORT: "5432" + DB_DATABASE: "{{ opnform_db_name }}" + DB_USERNAME: "{{ opnform_db_user }}" + DB_PASSWORD: "{{ opnform_db_password }}" + + REDIS_HOST: redis + REDIS_PORT: "6379" + + CACHE_STORE: redis + CACHE_DRIVER: redis + QUEUE_CONNECTION: redis + SESSION_DRIVER: redis + SESSION_LIFETIME: "120" + BROADCAST_CONNECTION: log + + FILESYSTEM_DISK: local + FILESYSTEM_DRIVER: local + LOCAL_FILESYSTEM_VISIBILITY: public + + MAIL_MAILER: "{{ opnform_mail_mailer }}" + MAIL_HOST: "{{ opnform_mail_host }}" + MAIL_PORT: "{{ opnform_mail_port }}" + MAIL_USERNAME: "{{ opnform_mail_username }}" + MAIL_PASSWORD: "{{ opnform_mail_password }}" + MAIL_ENCRYPTION: "{{ opnform_mail_encryption }}" + MAIL_FROM_ADDRESS: "{{ opnform_mail_from_address }}" + MAIL_FROM_NAME: "{{ opnform_mail_from_name }}" + + JWT_TTL: "1440" + JWT_SECRET: "{{ opnform_jwt_secret }}" + + PHP_MEMORY_LIMIT: "{{ opnform_php_memory_limit }}" + PHP_MAX_EXECUTION_TIME: "{{ opnform_php_max_execution_time }}" + PHP_UPLOAD_MAX_FILESIZE: "{{ opnform_php_upload_max_filesize }}" + PHP_POST_MAX_SIZE: "{{ opnform_php_post_max_size }}" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"] + interval: 30s + timeout: 15s + retries: 3 + start_period: 60s + networks: + - opnform-internal + + api-worker: + <<: *api-service + container_name: opnform-api-worker + command: ["php", "artisan", "queue:work"] + environment: + <<: *api-env + IS_API_WORKER: "true" + healthcheck: + test: ["CMD-SHELL", "pgrep -f 'php artisan queue:work' > /dev/null || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + api-scheduler: + <<: *api-service + container_name: opnform-api-scheduler + command: ["php", "artisan", "schedule:work"] + healthcheck: + test: + - "CMD-SHELL" + - "php /usr/share/nginx/html/artisan app:scheduler-status --mode=check --max-minutes=3 || exit 1" + interval: 60s + timeout: 30s + retries: 3 + start_period: 70s + + ui: + image: {{ opnform_client_image }} + container_name: opnform-ui + restart: unless-stopped + environment: + NUXT_PUBLIC_APP_URL: "{{ opnform_base_url }}" + NUXT_PUBLIC_API_BASE: "/api" + NUXT_PRIVATE_API_BASE: "http://ingress/api" + NUXT_PUBLIC_ENV: production + FRONT_API_SECRET: "{{ opnform_front_api_secret }}" + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/login || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 45s + networks: + - opnform-internal + + redis: + image: {{ opnform_redis_image }} + container_name: opnform-redis + restart: unless-stopped + volumes: + - {{ opnform_redis_data_dir }}:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 30s + timeout: 5s + networks: + - opnform-internal + + db: + image: {{ opnform_db_image }} + container_name: opnform-db + restart: unless-stopped + environment: + POSTGRES_DB: "{{ opnform_db_name }}" + POSTGRES_USER: "{{ opnform_db_user }}" + POSTGRES_PASSWORD: "{{ opnform_db_password }}" + volumes: + - {{ opnform_db_data_dir }}:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {{ opnform_db_user }}"] + interval: 30s + timeout: 5s + networks: + - opnform-internal + + ingress: + image: {{ opnform_ingress_image }} + container_name: opnform-ingress + restart: unless-stopped + volumes: + - ./nginx.conf:/etc/nginx/templates/default.conf.template:ro + environment: + NGINX_MAX_BODY_SIZE: "{{ opnform_nginx_max_body_size }}" + depends_on: + api: + condition: service_started + ui: + condition: service_started + healthcheck: + test: ["CMD-SHELL", "nginx -t && curl -f http://localhost/ || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - opnform-internal + - {{ opnform_traefik_network }} + labels: + - traefik.enable=true + - traefik.docker.network={{ opnform_traefik_network }} + - traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`) +{% if opnform_use_ssl %} + - traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure + - traefik.http.routers.{{ opnform_service_name }}.tls=true +{% else %} + - traefik.http.routers.{{ opnform_service_name }}.entrypoints=web +{% endif %} + - traefik.http.services.{{ opnform_service_name }}.loadbalancer.server.port=80 + +networks: + opnform-internal: + driver: bridge + {{ opnform_traefik_network }}: + external: true diff --git a/roles/opnform/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 new file mode 100644 index 0000000..fa3193b --- /dev/null +++ b/roles/opnform/templates/nginx.conf.j2 @@ -0,0 +1,43 @@ +map $original_uri $api_uri { + ~^/api(/.*$) $1; + default $original_uri; +} + +server { + listen 80; + server_name {{ opnform_domain }}; + root /app/public; + + client_max_body_size {% raw %}${NGINX_MAX_BODY_SIZE}{% endraw %}; + + access_log /dev/stdout; + error_log /dev/stderr error; + + index index.html index.htm index.php; + + location / { + proxy_http_version 1.1; + proxy_pass http://ui:3000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + } + + location ~/(api|open|local\/temp|forms\/assets)/ { + set $original_uri $uri; + try_files $uri $uri/ /index.php$is_args$args; + } + + location ~ \.php$ { + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass api:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php; + fastcgi_param REQUEST_URI $api_uri; + } +} diff --git a/roles/opnform/tests/inventory b/roles/opnform/tests/inventory new file mode 100644 index 0000000..712db59 --- /dev/null +++ b/roles/opnform/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost diff --git a/roles/opnform/tests/test.yml b/roles/opnform/tests/test.yml new file mode 100644 index 0000000..3ff9caa --- /dev/null +++ b/roles/opnform/tests/test.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - opnform \ No newline at end of file diff --git a/roles/opnform/vars/main.yml b/roles/opnform/vars/main.yml new file mode 100644 index 0000000..94900f8 --- /dev/null +++ b/roles/opnform/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for opnform \ No newline at end of file diff --git a/roles/talk/README.md b/roles/talk/README.md new file mode 100644 index 0000000..28652be --- /dev/null +++ b/roles/talk/README.md @@ -0,0 +1,78 @@ +# talk + +Deploys the Nextcloud Talk High Performance Backend (HPB) stack: + +- `nextcloud-spreed-signaling` (Strukturag) +- `janus-gateway` (canyan build, WebRTC MCU) +- `nats` (internal message broker) + +Designed to be paired with the `digitalboard.core.coturn` role (TURN/STUN) and registered in +Nextcloud via the new `digitalboard.core.nextcloud` `talk.yml` task. + +## Required variables + +| Variable | Description | +|---|---| +| `talk_domain` | Public host name (e.g. `signaling.digitalboard.ch`) | +| `talk_nextcloud_url` | Base URL of the Nextcloud instance the HPB talks back to | +| `talk_janus_public_ip` | Public IP used by Janus for ICE candidate gathering (nat_1_1_mapping) | +| `talk_backend_secret` | HMAC secret shared with Nextcloud Talk; loaded from `secrets/{host}/talk_backend_secret` | +| `talk_turn_secret` | Shared secret with coturn; loaded from `secrets/{host}/talk_turn_secret` (must equal `coturn_static_auth_secret`) | +| `talk_session_hashkey` | 32-byte hex; loaded from `secrets/{host}/talk_session_hashkey` | +| `talk_session_blockkey` | 32-byte hex; loaded from `secrets/{host}/talk_session_blockkey` | + +## Important variables + +| Variable | Default | Description | +|---|---|---| +| `talk_internal_domain` | `""` | Optional split-horizon FQDN (matches the second SAN on the coturn cert) | +| `talk_turn_servers` | `turns:.../443?transport=tcp,turn:.../443` | Comma-separated TURN URI list passed to the signaling server | +| `talk_turn_realm` | `stun.example.test` | Realm advertised to clients | +| `talk_janus_stun_server` | `stun.int.example.test` | STUN endpoint Janus uses for its own ICE; default points at the internal coturn name | +| `talk_janus_rtp_port_min/max` | `20000`/`21000` | UDP/TCP relay range opened on the Janus container | +| `talk_nextcloud_extra_host_ip` | `""` | Optional pin: bind the Nextcloud FQDN to a specific backend IP (bypasses hairpin/SNI) | +| `talk_signaling_image` | `strukturag/nextcloud-spreed-signaling:1.3.4` | Pinned | +| `talk_janus_image` | `canyan/janus-gateway:1.2.4` | Pinned | +| `talk_nats_image` | `nats:2.10-alpine` | Pinned | + +All defaults can be overridden per host_vars. The configurable image variables exist explicitly because +this stack is still under active development upstream and you may want to roll forward independently. + +## Secrets + +The role expects these files under `playbooks/secrets/{{ inventory_hostname }}/`, mode 0600: + +``` +talk_backend_secret # shared with Nextcloud Talk app (HPB shared secret) +talk_turn_secret # = coturn_static_auth_secret on the TURN host +talk_session_hashkey # 32-byte hex (openssl rand -hex 32) +talk_session_blockkey # 32-byte hex (openssl rand -hex 32) +``` + +If you prefer a different secret store, override the variables directly in host_vars. + +## What gets registered in Nextcloud + +The matching `digitalboard.core.nextcloud` task `talk.yml` runs: + +- `php occ talk:signaling:add ` — register HPB +- `php occ talk:turn:add` for each entry in `nextcloud_talk_turn_servers` — register TURN + +That part lives in the **nextcloud** role and runs when `nextcloud_enable_talk: true`. + +## Traefik + +The role assumes a `digitalboard.core.traefik` instance in `backend` mode runs on the same host +(picks up Docker container labels). The public `talk_domain` then needs to be exposed via the +**DMZ Traefik**, by adding an entry to `traefik_dmz_exposed_services` in the signaling host's +`host_vars`: + +```yaml +traefik_dmz_exposed_services: + - name: signaling + domain: signaling.digitalboard.ch + port: 443 + protocol: https +``` + +(The DMZ proxy aggregates exposed services from all `backend_servers` host_vars.) diff --git a/roles/talk/defaults/main.yml b/roles/talk/defaults/main.yml new file mode 100644 index 0000000..79a3a00 --- /dev/null +++ b/roles/talk/defaults/main.yml @@ -0,0 +1,74 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for talk (Nextcloud Talk High Performance Backend) + +# Base directories (inherited from base role) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +talk_service_name: signaling +talk_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ talk_service_name }}" +talk_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ talk_service_name }}" + +# --- Container images (pinned) --- +talk_signaling_image: "strukturag/nextcloud-spreed-signaling:1.3.4" +talk_janus_image: "canyan/janus-gateway:1.2.4" +talk_nats_image: "nats:2.10-alpine" + +# --- Networking --- +talk_traefik_network: "proxy" +talk_internal_network: "hpb_internal" + +# --- Public exposure --- +talk_use_ssl: true +talk_cert_resolver: "dns" +talk_domain: "signaling.example.test" # public domain (over DMZ Traefik) +talk_internal_domain: "" # optional split-horizon "int" domain (e.g. signaling.int.example.test) + +# --- Backend (Nextcloud) registration --- +# Nextcloud base URL the HPB talks back to. Must be reachable from the HPB container. +talk_nextcloud_url: "https://cloud.example.test" +# Pin Nextcloud domain to a backend IP via extra_hosts to bypass DMZ hairpin/SNI issues +talk_nextcloud_extra_host_ip: "" # e.g. "172.16.9.88" — empty disables the pin + +# Backend HMAC secret shared with Nextcloud Talk. +# Pattern follows playbooks/secrets/{host}/; override the lookup with vault if desired. +talk_backend_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_backend_secret') }}" + +# --- TURN integration --- +# Shared secret with coturn (--static-auth-secret). Must match coturn_static_auth_secret on the TURN host. +talk_turn_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_turn_secret') }}" +# TURN server URI list as understood by the signaling server. +# Defaults follow IANA standards (3478/5349). Override to ":443" in restrictive +# network environments where coturn binds on 443. +talk_turn_servers: "turns:stun.example.test:5349?transport=tcp,turn:stun.example.test:3478" +talk_turn_realm: "stun.example.test" +talk_turn_apikey: "" # optional; if empty a random one is generated on first run + +# --- Session keys (server.conf [sessions]) --- +# 32-byte hex strings. Loaded from secrets dir like the other shared secrets. +talk_session_hashkey: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_session_hashkey') }}" +talk_session_blockkey: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_session_blockkey') }}" + +# --- MCU (Janus) --- +talk_mcu_type: "janus" +talk_janus_public_ip: "" # set in host_vars; goes into janus nat_1_1_mapping +talk_janus_rtp_port_min: 20000 +talk_janus_rtp_port_max: 21000 +# STUN server Janus uses for its own ICE candidate gathering. Default points to internal coturn DNS name. +talk_janus_stun_server: "stun.int.example.test" +talk_janus_stun_port: 5349 +talk_janus_ice_lite: true +talk_janus_ice_tcp: true + +# --- Trusted proxies / allowed hosts for the signaling [app] section --- +talk_trusted_proxies: + - "172.16.0.0/12" + - "192.168.0.0/16" + - "10.0.0.0/8" +talk_allowed_hosts: + - "172.16.0.0/12" + +# --- Extra hosts forwarded to all three containers --- +# Pre-populated with the Nextcloud pin if talk_nextcloud_extra_host_ip is set; you can append more here. +talk_extra_hosts: [] diff --git a/roles/talk/handlers/main.yml b/roles/talk/handlers/main.yml new file mode 100644 index 0000000..645244d --- /dev/null +++ b/roles/talk/handlers/main.yml @@ -0,0 +1,8 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for talk + +- name: Restart signaling stack + community.docker.docker_compose_v2: + project_src: "{{ talk_docker_compose_dir }}" + state: restarted diff --git a/roles/talk/meta/argument_specs.yml b/roles/talk/meta/argument_specs.yml new file mode 100644 index 0000000..9117ea8 --- /dev/null +++ b/roles/talk/meta/argument_specs.yml @@ -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//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//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//talk_session_hashkey). + talk_session_blockkey: + type: str + required: true + description: + - 32-byte hex string. Default lookup reads + C(playbooks/secrets//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. diff --git a/roles/talk/meta/main.yml b/roles/talk/meta/main.yml new file mode 100644 index 0000000..7857f43 --- /dev/null +++ b/roles/talk/meta/main.yml @@ -0,0 +1,15 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: Digital Board Team + description: Deploy Nextcloud Talk High Performance Backend (nextcloud-spreed-signaling + Janus + NATS) + company: digitalboard.ch + license: GPL-2.0-or-later + min_ansible_version: "2.14" + galaxy_tags: + - nextcloud + - talk + - signaling + - hpb + - janus + - webrtc +dependencies: [] diff --git a/roles/talk/tasks/main.yml b/roles/talk/tasks/main.yml new file mode 100644 index 0000000..3a984cf --- /dev/null +++ b/roles/talk/tasks/main.yml @@ -0,0 +1,85 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for talk (HPB) + +- name: Assert minimum configuration + ansible.builtin.assert: + that: + - talk_domain | length > 0 + - talk_nextcloud_url | length > 0 + - talk_backend_secret | length > 0 + - talk_turn_secret | length > 0 + - talk_janus_public_ip | length > 0 + - talk_session_hashkey | length > 0 + - talk_session_blockkey | length > 0 + fail_msg: > + Required talk_* variables missing. + Set talk_domain, talk_nextcloud_url, talk_janus_public_ip in host_vars + and place backend/turn/session secrets in playbooks/secrets/{{ inventory_hostname }}/. + +- name: Create talk compose directory + ansible.builtin.file: + path: "{{ talk_docker_compose_dir }}" + state: directory + mode: "0755" + +- name: Create signaling subdirectories (signaling + janus configs) + ansible.builtin.file: + path: "{{ talk_docker_compose_dir }}/{{ item }}" + state: directory + mode: "0755" + loop: + - signaling + - janus + +- name: Create signaling data directory + ansible.builtin.file: + path: "{{ talk_docker_volume_dir }}/signaling/data" + state: directory + mode: "0755" + +- name: Ensure proxy network exists (created externally by Traefik role normally) + community.docker.docker_network: + name: "{{ talk_traefik_network }}" + state: present + +- name: Render signaling server.conf + ansible.builtin.template: + src: server.conf.j2 + dest: "{{ talk_docker_compose_dir }}/signaling/server.conf" + mode: "0640" + no_log: true + notify: Restart signaling stack + +- name: Render Janus main config + ansible.builtin.template: + src: janus.jcfg.j2 + dest: "{{ talk_docker_compose_dir }}/janus/janus.jcfg" + mode: "0644" + notify: Restart signaling stack + +- name: Render Janus websockets transport config + ansible.builtin.template: + src: janus.transport.websockets.jcfg.j2 + dest: "{{ talk_docker_compose_dir }}/janus/janus.transport.websockets.jcfg" + mode: "0644" + notify: Restart signaling stack + +- name: Render Janus logger config + ansible.builtin.template: + src: janus.logger.jcfg.j2 + dest: "{{ talk_docker_compose_dir }}/janus/janus.logger.jcfg" + mode: "0644" + notify: Restart signaling stack + +- name: Render docker-compose.yml + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ talk_docker_compose_dir }}/docker-compose.yml" + mode: "0644" + notify: Restart signaling stack + +- name: Start signaling stack + community.docker.docker_compose_v2: + project_src: "{{ talk_docker_compose_dir }}" + state: present diff --git a/roles/talk/templates/docker-compose.yml.j2 b/roles/talk/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..f207186 --- /dev/null +++ b/roles/talk/templates/docker-compose.yml.j2 @@ -0,0 +1,124 @@ +{# Build the effective extra_hosts list once #} +{% set _extra_hosts = [] %} +{% if talk_nextcloud_extra_host_ip | length > 0 %} +{% set _ = _extra_hosts.append((talk_nextcloud_url | urlsplit('hostname')) ~ ':' ~ talk_nextcloud_extra_host_ip) %} +{% endif %} +{% for h in talk_extra_hosts %} +{% set _ = _extra_hosts.append(h) %} +{% endfor %} +networks: + {{ talk_traefik_network }}: + external: true + {{ talk_internal_network }}: + driver: bridge + +services: + nats: + image: {{ talk_nats_image }} + container_name: nats + restart: unless-stopped +{% if _extra_hosts | length > 0 %} + extra_hosts: +{% for h in _extra_hosts %} + - "{{ h }}" +{% endfor %} +{% endif %} + command: > + -js + -m 8222 + -p 4222 + healthcheck: + test: ["CMD", "nc", "-z", "localhost", "4222"] + interval: 10s + timeout: 3s + retries: 10 + networks: + - {{ talk_internal_network }} + + janus: + image: {{ talk_janus_image }} + container_name: janus + restart: unless-stopped +{% if _extra_hosts | length > 0 %} + extra_hosts: +{% for h in _extra_hosts %} + - "{{ h }}" +{% endfor %} +{% endif %} + environment: + PUBLIC_IP: "{{ talk_janus_public_ip }}" + RTP_RANGE: "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}" + volumes: + - ./janus/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro + - ./janus/janus.transport.websockets.jcfg:/usr/local/etc/janus/janus.transport.websockets.jcfg:ro + - ./janus/janus.logger.jcfg:/usr/local/etc/janus/janus.logger.jcfg:ro + networks: + - {{ talk_internal_network }} + ports: + - "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}:{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}/udp" + - "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}:{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}/tcp" + ulimits: + nofile: + soft: 65536 + hard: 65536 + + signaling: + image: {{ talk_signaling_image }} + container_name: signaling + restart: unless-stopped + depends_on: + nats: + condition: service_healthy +{% if _extra_hosts | length > 0 %} + extra_hosts: +{% for h in _extra_hosts %} + - "{{ h }}" +{% endfor %} +{% endif %} + volumes: + - ./signaling/server.conf:/config/server.conf:ro + - {{ talk_docker_volume_dir }}/signaling/data:/var/lib/signaling + networks: + - {{ talk_traefik_network }} + - {{ talk_internal_network }} + labels: + - traefik.enable=true + - traefik.docker.network={{ talk_traefik_network }} + + # Public WebSocket route (/spreed) + - traefik.http.routers.signal-public.rule=Host(`{{ talk_domain }}`) && PathPrefix(`/spreed`) + - traefik.http.routers.signal-public.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }} +{% if talk_use_ssl %} + - traefik.http.routers.signal-public.tls=true + - traefik.http.routers.signal-public.tls.certresolver={{ talk_cert_resolver }} +{% endif %} + - traefik.http.routers.signal-public.service=signal-svc + - traefik.http.routers.signal-public.middlewares=signal-ws + + # Public backend API route (/api/) + - traefik.http.routers.signal-backend.rule=Host(`{{ talk_domain }}`) && PathPrefix(`/api/`) + - traefik.http.routers.signal-backend.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }} +{% if talk_use_ssl %} + - traefik.http.routers.signal-backend.tls=true + - traefik.http.routers.signal-backend.tls.certresolver={{ talk_cert_resolver }} +{% endif %} + - traefik.http.routers.signal-backend.service=signal-svc + +{% if talk_internal_domain | length > 0 %} + # Internal split-horizon route (full host on int domain, WebSocket-aware) + - traefik.http.routers.signal-int.rule=Host(`{{ talk_internal_domain }}`) + - traefik.http.routers.signal-int.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }} +{% if talk_use_ssl %} + - traefik.http.routers.signal-int.tls=true + - traefik.http.routers.signal-int.tls.certresolver={{ talk_cert_resolver }} +{% endif %} + - traefik.http.routers.signal-int.service=signal-svc + - traefik.http.routers.signal-int.middlewares=signal-ws +{% endif %} + + # Common service + - traefik.http.services.signal-svc.loadbalancer.server.port=8181 + + # WebSocket upgrade headers + - traefik.http.middlewares.signal-ws.headers.customrequestheaders.Upgrade=websocket + - traefik.http.middlewares.signal-ws.headers.customrequestheaders.Connection=Upgrade diff --git a/roles/talk/templates/janus.jcfg.j2 b/roles/talk/templates/janus.jcfg.j2 new file mode 100644 index 0000000..7c0a3bc --- /dev/null +++ b/roles/talk/templates/janus.jcfg.j2 @@ -0,0 +1,28 @@ +general: { + configs_folder = "/usr/local/etc/janus" + log_to_stdout = true +} + +nat: { + nat_1_1_mapping = "{{ talk_janus_public_ip }}" + ice_lite = {{ talk_janus_ice_lite | string | lower }} + ice_tcp = {{ talk_janus_ice_tcp | string | lower }} + + stun_server = "{{ talk_janus_stun_server }}" + stun_port = {{ talk_janus_stun_port }} + + rtp_port_range = "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}" +} + +media: { + rtp_port_range = "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}" +} + +transports: { + websockets: { + ws = true + ws_port = 8188 + ws_interface = "0.0.0.0" + ws_ip = "0.0.0.0" + } +} diff --git a/roles/talk/templates/janus.logger.jcfg.j2 b/roles/talk/templates/janus.logger.jcfg.j2 new file mode 100644 index 0000000..6e1c4e4 --- /dev/null +++ b/roles/talk/templates/janus.logger.jcfg.j2 @@ -0,0 +1,3 @@ +general: { + enabled = true +} diff --git a/roles/talk/templates/janus.transport.websockets.jcfg.j2 b/roles/talk/templates/janus.transport.websockets.jcfg.j2 new file mode 100644 index 0000000..b5cb5a7 --- /dev/null +++ b/roles/talk/templates/janus.transport.websockets.jcfg.j2 @@ -0,0 +1,7 @@ +general: { + ws = true + ws_port = 8188 + ws_interface = "0.0.0.0" + ws_pingpong_trigger = 60 + ws_pingpong_timeout = 30 +} diff --git a/roles/talk/templates/server.conf.j2 b/roles/talk/templates/server.conf.j2 new file mode 100644 index 0000000..6d86c0a --- /dev/null +++ b/roles/talk/templates/server.conf.j2 @@ -0,0 +1,33 @@ +[http] +listen = 0.0.0.0:8181 +base_url = https://{{ talk_domain }} + +[backend] +backends = cloud + +[cloud] +secret = {{ talk_backend_secret }} +url = {{ talk_nextcloud_url }} + +[nats] +url = nats://nats:4222 + +[mcu] +type = {{ talk_mcu_type }} +url = ws://janus:8188/ + +[sessions] +hashkey = {{ talk_session_hashkey }} +blockkey = {{ talk_session_blockkey }} + +[turn] +servers = {{ talk_turn_servers }} +realm = {{ talk_turn_realm }} +{% if talk_turn_apikey | length > 0 %} +apikey = {{ talk_turn_apikey }} +{% endif %} +secret = {{ talk_turn_secret }} + +[app] +trustedproxies = {{ talk_trusted_proxies | join(',') }} +allowedhosts = {{ talk_allowed_hosts | join(',') }} diff --git a/roles/talk/tests/inventory b/roles/talk/tests/inventory new file mode 100644 index 0000000..eec845d --- /dev/null +++ b/roles/talk/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost \ No newline at end of file diff --git a/roles/talk/tests/test.yml b/roles/talk/tests/test.yml new file mode 100644 index 0000000..a3c7d07 --- /dev/null +++ b/roles/talk/tests/test.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - talk diff --git a/roles/talk/vars/main.yml b/roles/talk/vars/main.yml new file mode 100644 index 0000000..a131766 --- /dev/null +++ b/roles/talk/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for talk