diff --git a/galaxy.yml b/galaxy.yml index c208d8f..91cd337 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -19,7 +19,6 @@ 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/bookstack/README.md b/roles/bookstack/README.md deleted file mode 100644 index 25fb789..0000000 --- a/roles/bookstack/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# 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 deleted file mode 100644 index 3efbadb..0000000 --- a/roles/bookstack/defaults/main.yml +++ /dev/null @@ -1,85 +0,0 @@ -#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 deleted file mode 100644 index eb6a769..0000000 --- a/roles/bookstack/handlers/main.yml +++ /dev/null @@ -1,19 +0,0 @@ -#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 deleted file mode 100644 index 8546cde..0000000 --- a/roles/bookstack/meta/argument_specs.yml +++ /dev/null @@ -1,194 +0,0 @@ ---- -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 deleted file mode 100644 index a6e941d..0000000 --- a/roles/bookstack/meta/main.yml +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 1ea325b..0000000 --- a/roles/bookstack/tasks/main.yml +++ /dev/null @@ -1,223 +0,0 @@ -#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 deleted file mode 100644 index 65217c2..0000000 --- a/roles/bookstack/templates/backup.sh.j2 +++ /dev/null @@ -1,41 +0,0 @@ -#!/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 deleted file mode 100644 index cb63795..0000000 --- a/roles/bookstack/templates/bookstack-backup.service.j2 +++ /dev/null @@ -1,12 +0,0 @@ -# {{ 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 deleted file mode 100644 index e13238d..0000000 --- a/roles/bookstack/templates/bookstack-backup.timer.j2 +++ /dev/null @@ -1,11 +0,0 @@ -# {{ 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 deleted file mode 100644 index 863e316..0000000 --- a/roles/bookstack/templates/docker-compose.yml.j2 +++ /dev/null @@ -1,87 +0,0 @@ -#---------------------------------------------------------------------# -# 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 deleted file mode 100644 index 2fbb50c..0000000 --- a/roles/bookstack/tests/inventory +++ /dev/null @@ -1 +0,0 @@ -localhost diff --git a/roles/bookstack/tests/test.yml b/roles/bookstack/tests/test.yml deleted file mode 100644 index 15b9be3..0000000 --- a/roles/bookstack/tests/test.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -- hosts: localhost - remote_user: root - roles: - - bookstack diff --git a/roles/bookstack/vars/main.yml b/roles/bookstack/vars/main.yml deleted file mode 100644 index e04b89a..0000000 --- a/roles/bookstack/vars/main.yml +++ /dev/null @@ -1,3 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# vars file for bookstack diff --git a/roles/coturn/README.md b/roles/coturn/README.md deleted file mode 100644 index 13d1c3e..0000000 --- a/roles/coturn/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# 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 deleted file mode 100644 index 580d9da..0000000 --- a/roles/coturn/defaults/main.yml +++ /dev/null @@ -1,77 +0,0 @@ -#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 deleted file mode 100644 index 0abd12f..0000000 --- a/roles/coturn/handlers/main.yml +++ /dev/null @@ -1,10 +0,0 @@ -#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 deleted file mode 100644 index 55a9b3e..0000000 --- a/roles/coturn/meta/argument_specs.yml +++ /dev/null @@ -1,148 +0,0 @@ ---- -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 deleted file mode 100644 index 68d93a9..0000000 --- a/roles/coturn/meta/main.yml +++ /dev/null @@ -1,15 +0,0 @@ -#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 deleted file mode 100644 index cf9c15a..0000000 --- a/roles/coturn/tasks/main.yml +++ /dev/null @@ -1,110 +0,0 @@ -#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 deleted file mode 100644 index 42bdcb5..0000000 --- a/roles/coturn/templates/docker-compose.yml.j2 +++ /dev/null @@ -1,78 +0,0 @@ -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 deleted file mode 100644 index eec845d..0000000 --- a/roles/coturn/tests/inventory +++ /dev/null @@ -1,2 +0,0 @@ -#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 deleted file mode 100644 index 828e0fb..0000000 --- a/roles/coturn/tests/tests.yml +++ /dev/null @@ -1,6 +0,0 @@ -#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 deleted file mode 100644 index f2a4ea3..0000000 --- a/roles/coturn/vars/main.yml +++ /dev/null @@ -1,3 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# vars file for coturn diff --git a/plugins/filter/homarr_layout.py b/roles/homarr/filter_plugins/homarr_layout.py similarity index 100% rename from plugins/filter/homarr_layout.py rename to roles/homarr/filter_plugins/homarr_layout.py diff --git a/roles/homarr/filter_plugins/tests/test_homarr_layout.py b/roles/homarr/filter_plugins/tests/test_homarr_layout.py index a96d672..3a49f2b 100644 --- a/roles/homarr/filter_plugins/tests/test_homarr_layout.py +++ b/roles/homarr/filter_plugins/tests/test_homarr_layout.py @@ -15,11 +15,7 @@ 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__), '..', '..', '..', '..', - 'plugins', 'filter') -) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) import pytest # noqa: E402 diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index ffb0bb7..9d00cde 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 | digitalboard.core.homarr_compute_layouts }}" + homarr_layout: "{{ homarr_apps | 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 0c96046..7535b5a 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -61,26 +61,6 @@ 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 e33088b..8d2a5cd 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -91,7 +91,3 @@ - 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 deleted file mode 100644 index aaf67e3..0000000 --- a/roles/nextcloud/tasks/talk.yml +++ /dev/null @@ -1,70 +0,0 @@ -#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 deleted file mode 100644 index 2dfad2d..0000000 --- a/roles/opnform/README.md +++ /dev/null @@ -1,169 +0,0 @@ -# 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 deleted file mode 100644 index 0f61c3a..0000000 --- a/roles/opnform/defaults/main.yml +++ /dev/null @@ -1,109 +0,0 @@ -#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 deleted file mode 100644 index 1c0b422..0000000 --- a/roles/opnform/handlers/main.yml +++ /dev/null @@ -1,8 +0,0 @@ -#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 deleted file mode 100644 index 9fbfc7a..0000000 --- a/roles/opnform/meta/argument_specs.yml +++ /dev/null @@ -1,220 +0,0 @@ ---- -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 deleted file mode 100644 index 8a56a7b..0000000 --- a/roles/opnform/meta/main.yml +++ /dev/null @@ -1,16 +0,0 @@ -#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 deleted file mode 100644 index 68e093b..0000000 --- a/roles/opnform/tasks/main.yml +++ /dev/null @@ -1,265 +0,0 @@ -#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 deleted file mode 100644 index de88a33..0000000 --- a/roles/opnform/templates/docker-compose.yml.j2 +++ /dev/null @@ -1,189 +0,0 @@ -#---------------------------------------------------------------------# -# 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 deleted file mode 100644 index fa3193b..0000000 --- a/roles/opnform/templates/nginx.conf.j2 +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 712db59..0000000 --- a/roles/opnform/tests/inventory +++ /dev/null @@ -1,2 +0,0 @@ -#SPDX-License-Identifier: MIT-0 -localhost diff --git a/roles/opnform/tests/test.yml b/roles/opnform/tests/test.yml deleted file mode 100644 index 3ff9caa..0000000 --- a/roles/opnform/tests/test.yml +++ /dev/null @@ -1,6 +0,0 @@ -#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 deleted file mode 100644 index 94900f8..0000000 --- a/roles/opnform/vars/main.yml +++ /dev/null @@ -1,3 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# vars file for opnform \ No newline at end of file diff --git a/roles/send/tasks/main.yml b/roles/send/tasks/main.yml index 2ed91c9..9ed8dd8 100644 --- a/roles/send/tasks/main.yml +++ b/roles/send/tasks/main.yml @@ -2,20 +2,6 @@ --- # tasks file for send -- name: Assert S3 backend configuration when enabled - ansible.builtin.assert: - that: - - send_s3_endpoint | length > 0 - - send_s3_bucket | length > 0 - - send_s3_access_key | length > 0 - - send_s3_secret_key | length > 0 - fail_msg: >- - send_storage_backend is 's3' but one or more of send_s3_endpoint, - send_s3_bucket, send_s3_access_key, send_s3_secret_key is unset. - Provide via OpenBao, Ansible Vault or extra-vars — or switch - send_storage_backend to 'local'. - when: send_storage_backend == "s3" - - name: Create docker compose directory ansible.builtin.file: path: "{{ send_docker_compose_dir }}" diff --git a/roles/talk/README.md b/roles/talk/README.md deleted file mode 100644 index 28652be..0000000 --- a/roles/talk/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# 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 deleted file mode 100644 index 79a3a00..0000000 --- a/roles/talk/defaults/main.yml +++ /dev/null @@ -1,74 +0,0 @@ -#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 deleted file mode 100644 index 645244d..0000000 --- a/roles/talk/handlers/main.yml +++ /dev/null @@ -1,8 +0,0 @@ -#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 deleted file mode 100644 index 9117ea8..0000000 --- a/roles/talk/meta/argument_specs.yml +++ /dev/null @@ -1,161 +0,0 @@ ---- -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 deleted file mode 100644 index 7857f43..0000000 --- a/roles/talk/meta/main.yml +++ /dev/null @@ -1,15 +0,0 @@ -#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 deleted file mode 100644 index 3a984cf..0000000 --- a/roles/talk/tasks/main.yml +++ /dev/null @@ -1,85 +0,0 @@ -#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 deleted file mode 100644 index f207186..0000000 --- a/roles/talk/templates/docker-compose.yml.j2 +++ /dev/null @@ -1,124 +0,0 @@ -{# 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 deleted file mode 100644 index 7c0a3bc..0000000 --- a/roles/talk/templates/janus.jcfg.j2 +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 6e1c4e4..0000000 --- a/roles/talk/templates/janus.logger.jcfg.j2 +++ /dev/null @@ -1,3 +0,0 @@ -general: { - enabled = true -} diff --git a/roles/talk/templates/janus.transport.websockets.jcfg.j2 b/roles/talk/templates/janus.transport.websockets.jcfg.j2 deleted file mode 100644 index b5cb5a7..0000000 --- a/roles/talk/templates/janus.transport.websockets.jcfg.j2 +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 6d86c0a..0000000 --- a/roles/talk/templates/server.conf.j2 +++ /dev/null @@ -1,33 +0,0 @@ -[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 deleted file mode 100644 index eec845d..0000000 --- a/roles/talk/tests/inventory +++ /dev/null @@ -1,2 +0,0 @@ -#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 deleted file mode 100644 index a3c7d07..0000000 --- a/roles/talk/tests/test.yml +++ /dev/null @@ -1,6 +0,0 @@ -#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 deleted file mode 100644 index a131766..0000000 --- a/roles/talk/vars/main.yml +++ /dev/null @@ -1,3 +0,0 @@ -#SPDX-License-Identifier: MIT-0 ---- -# vars file for talk