From de947dd8a4a657355dd88eb3c7368572f81c52c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Wed, 20 May 2026 22:00:32 +0200 Subject: [PATCH 01/19] feat(send): add role for self-hosted Send file-share service Deploys timvisee/send with a Redis backend behind Traefik. Supports local-disk or S3 storage (e.g. via the garage role). Uses the shared `*_domains` list convention so the router can accept internal *.int.* names alongside the canonical BASE_URL host. --- roles/send/README.md | 60 +++++++++++++++++++ roles/send/defaults/main.yml | 53 +++++++++++++++++ roles/send/handlers/main.yml | 9 +++ roles/send/meta/main.yml | 14 +++++ roles/send/tasks/main.yml | 28 +++++++++ roles/send/templates/docker-compose.yml.j2 | 69 ++++++++++++++++++++++ roles/send/vars/main.yml | 3 + 7 files changed, 236 insertions(+) create mode 100644 roles/send/README.md create mode 100644 roles/send/defaults/main.yml create mode 100644 roles/send/handlers/main.yml create mode 100644 roles/send/meta/main.yml create mode 100644 roles/send/tasks/main.yml create mode 100644 roles/send/templates/docker-compose.yml.j2 create mode 100644 roles/send/vars/main.yml diff --git a/roles/send/README.md b/roles/send/README.md new file mode 100644 index 0000000..339628b --- /dev/null +++ b/roles/send/README.md @@ -0,0 +1,60 @@ +Send +==== + +Deploys a self-hosted [Send](https://github.com/timvisee/send) instance +(timvisee fork of the discontinued Mozilla Send) with a Redis backend +behind Traefik, using Docker Compose. + +Requirements +------------ + +- Docker + `docker compose` plugin on the target host +- Traefik (role `digitalboard.core.traefik`) reachable via an external + Docker network named `proxy` (default) +- DNS for each entry in `send_domains` pointing at the reverse proxy +- Optional: a Garage S3 bucket if `send_storage_backend: s3` + +Role Variables +-------------- + +Important defaults (see `defaults/main.yml` for the full list): + +| Variable | Default | Description | +|---|---|---| +| `send_domains` | `["send.local.test"]` | FQDNs the router accepts; first entry is the canonical BASE_URL | +| `send_image` | `registry.gitlab.com/timvisee/send:latest` | Send container image | +| `send_max_file_size` | `1073741824` | Max upload size in bytes (1 GiB) | +| `send_max_expire_seconds` | `604800` | Max share lifetime (7 d) | +| `send_storage_backend` | `local` | `local` (volume) or `s3` | +| `send_s3_*` | `""` | S3 endpoint/bucket/key/secret (when backend is `s3`) | +| `send_use_ssl` | `true` | Issue Traefik labels for the `websecure` entrypoint | + +Dependencies +------------ + +None. + +Example Playbook +---------------- + +```yaml +- hosts: send_servers + become: true + roles: + - digitalboard.core.send +``` + +With S3 (Garage) backend: + +```yaml +send_storage_backend: s3 +send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domain'] }}" +send_s3_bucket: "send" +send_s3_access_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['key_id'] }}" +send_s3_secret_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['secret_key'] }}" +``` + +License +------- + +MIT diff --git a/roles/send/defaults/main.yml b/roles/send/defaults/main.yml new file mode 100644 index 0000000..ba3aecc --- /dev/null +++ b/roles/send/defaults/main.yml @@ -0,0 +1,53 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for send + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# Send-specific configuration +send_service_name: send +send_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ send_service_name }}" +send_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ send_service_name }}" + +# Service configuration +# FQDNs the send router accepts. The first entry is the canonical +# domain (used as BASE_URL); further entries cover internal *.int.* +# names so backend uploads can hit us without hairpinning via DMZ. +send_domains: + - "send.local.test" +send_image: "registry.gitlab.com/timvisee/send:latest" +send_port: 1443 +send_extra_hosts: [] + +# Redis backend +send_redis_image: "redis:7-alpine" +send_redis_service_name: "send-redis" + +# Send application configuration +# https://github.com/timvisee/send/blob/master/server/config.js +send_max_file_size: 1073741824 # 1 GiB in bytes +send_default_downloads: 1 +send_max_downloads: 100 +send_default_expire_seconds: 86400 # 24h +send_max_expire_seconds: 604800 # 7d +send_max_files_per_archive: 64 +send_download_counts: "1,2,3,4,5,20,50,100" +send_expire_times_seconds: "300,3600,86400,604800" + +# Storage backend: "local" (volume) or "s3" +send_storage_backend: "local" + +# S3 backend (only used when send_storage_backend == "s3") +send_s3_endpoint: "" +send_s3_bucket: "" +send_s3_region: "us-east-1" +send_s3_access_key: "" +send_s3_secret_key: "" +send_s3_use_path_style: true + +# Traefik configuration +send_traefik_network: "proxy" +send_internal_network: "send_internal" +send_use_ssl: true diff --git a/roles/send/handlers/main.yml b/roles/send/handlers/main.yml new file mode 100644 index 0000000..cb83189 --- /dev/null +++ b/roles/send/handlers/main.yml @@ -0,0 +1,9 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for send + +- name: restart send + community.docker.docker_compose_v2: + project_src: "{{ send_docker_compose_dir }}" + state: present + recreate: always diff --git a/roles/send/meta/main.yml b/roles/send/meta/main.yml new file mode 100644 index 0000000..79dedb1 --- /dev/null +++ b/roles/send/meta/main.yml @@ -0,0 +1,14 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: digitalboard + description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose + license: MIT + + min_ansible_version: 2.14 + + galaxy_tags: + - send + - filesharing + - docker + +dependencies: [] diff --git a/roles/send/tasks/main.yml b/roles/send/tasks/main.yml new file mode 100644 index 0000000..c79405a --- /dev/null +++ b/roles/send/tasks/main.yml @@ -0,0 +1,28 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for send + +- name: Create docker compose directory + file: + path: "{{ send_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create local upload directory + file: + path: "{{ send_docker_volume_dir }}/uploads" + state: directory + mode: '0755' + when: send_storage_backend == "local" + +- name: Create docker-compose file for send + template: + src: docker-compose.yml.j2 + dest: "{{ send_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + notify: restart send + +- name: Start send container + community.docker.docker_compose_v2: + project_src: "{{ send_docker_compose_dir }}" + state: present diff --git a/roles/send/templates/docker-compose.yml.j2 b/roles/send/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..a6733bb --- /dev/null +++ b/roles/send/templates/docker-compose.yml.j2 @@ -0,0 +1,69 @@ +services: + {{ send_service_name }}: + image: {{ send_image }} + container_name: {{ send_service_name }} + restart: unless-stopped + depends_on: + - {{ send_redis_service_name }} + networks: + - {{ send_traefik_network }} + - {{ send_internal_network }} +{% if send_extra_hosts is defined and send_extra_hosts | length > 0 %} + extra_hosts: +{% for host in send_extra_hosts %} + - "{{ host }}" +{% endfor %} +{% endif %} + environment: +{% if send_use_ssl %} + BASE_URL: "https://{{ send_domains[0] }}" +{% else %} + BASE_URL: "http://{{ send_domains[0] }}" +{% endif %} + REDIS_HOST: "{{ send_redis_service_name }}" + REDIS_PORT: "6379" + MAX_FILE_SIZE: "{{ send_max_file_size }}" + DEFAULT_DOWNLOADS: "{{ send_default_downloads }}" + MAX_DOWNLOADS: "{{ send_max_downloads }}" + DEFAULT_EXPIRE_SECONDS: "{{ send_default_expire_seconds }}" + MAX_EXPIRE_SECONDS: "{{ send_max_expire_seconds }}" + MAX_FILES_PER_ARCHIVE: "{{ send_max_files_per_archive }}" + DOWNLOAD_COUNTS: "{{ send_download_counts }}" + EXPIRE_TIMES_SECONDS: "{{ send_expire_times_seconds }}" +{% if send_storage_backend == "s3" %} + S3_BUCKET: "{{ send_s3_bucket }}" + S3_ENDPOINT: "{{ send_s3_endpoint }}" + S3_USE_PATH_STYLE_ENDPOINT: "{{ 'true' if send_s3_use_path_style else 'false' }}" + AWS_ACCESSKEYID: "{{ send_s3_access_key }}" + AWS_SECRETACCESSKEY: "{{ send_s3_secret_key }}" + AWS_REGION: "{{ send_s3_region }}" +{% else %} + FILE_DIR: "/uploads" + volumes: + - {{ send_docker_volume_dir }}/uploads:/uploads +{% endif %} + labels: + - traefik.enable=true + - traefik.docker.network={{ send_traefik_network }} + - traefik.http.routers.{{ send_service_name }}.rule=Host({% for d in send_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) + - traefik.http.services.{{ send_service_name }}.loadbalancer.server.port={{ send_port }} +{% if send_use_ssl %} + - traefik.http.routers.{{ send_service_name }}.entrypoints=websecure + - traefik.http.routers.{{ send_service_name }}.tls=true +{% else %} + - traefik.http.routers.{{ send_service_name }}.entrypoints=web +{% endif %} + + {{ send_redis_service_name }}: + image: {{ send_redis_image }} + container_name: {{ send_redis_service_name }} + restart: unless-stopped + networks: + - {{ send_internal_network }} + volumes: + - {{ send_docker_volume_dir }}/redis:/data + +networks: + {{ send_internal_network }}: + {{ send_traefik_network }}: + external: true diff --git a/roles/send/vars/main.yml b/roles/send/vars/main.yml new file mode 100644 index 0000000..b2a6b30 --- /dev/null +++ b/roles/send/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for send From 53e80ad7be1b8653f13cbc86fb08c84edfdb365e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Wed, 13 May 2026 17:23:34 +0200 Subject: [PATCH 02/19] chore: add new role for OpnForm --- roles/OpnForm/README.md | 126 ++++++++++++ roles/OpnForm/defaults/main.yml | 71 +++++++ roles/OpnForm/handlers/main.yml | 8 + roles/OpnForm/meta/main.yml | 35 ++++ roles/OpnForm/tasks/main.yml | 117 +++++++++++ roles/OpnForm/templates/docker-compose.yml.j2 | 189 ++++++++++++++++++ roles/OpnForm/templates/nginx.conf.j2 | 43 ++++ roles/OpnForm/tests/inventory | 2 + roles/OpnForm/tests/test.yml | 6 + roles/OpnForm/vars/main.yml | 3 + 10 files changed, 600 insertions(+) create mode 100644 roles/OpnForm/README.md create mode 100644 roles/OpnForm/defaults/main.yml create mode 100644 roles/OpnForm/handlers/main.yml create mode 100644 roles/OpnForm/meta/main.yml create mode 100644 roles/OpnForm/tasks/main.yml create mode 100644 roles/OpnForm/templates/docker-compose.yml.j2 create mode 100644 roles/OpnForm/templates/nginx.conf.j2 create mode 100644 roles/OpnForm/tests/inventory create mode 100644 roles/OpnForm/tests/test.yml create mode 100644 roles/OpnForm/vars/main.yml diff --git a/roles/OpnForm/README.md b/roles/OpnForm/README.md new file mode 100644 index 0000000..67e5436 --- /dev/null +++ b/roles/OpnForm/README.md @@ -0,0 +1,126 @@ +# 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-create an admin user (use the default credentials below) +- 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 + +After the role completes, OpnForm seeds a default admin user. Visit +the URL in `opnform_base_url` and log in with: + +- Email: `admin@opnform.com` +- Password: `password` + +On first login OpnForm will prompt you to change email and password. +Self-hosted instances disable public registration after this — invite +further users via the Admin UI. + +### If the login does not respond + +The DB seed may have failed. Re-run it manually: + +```bash +cd /etc/docker/compose/opnform +docker compose exec api php artisan migrate:refresh --seed +docker compose exec api php artisan app:init-project +``` + +## OIDC setup (stage 2, not yet automated) + +Manual setup via the Admin UI is currently the supported path: + +1. Settings → Identity Connections → Add Connection +2. Provider: OIDC +3. Issuer: `https://auth.digitalboard.ch/realms/Digitalboard` +4. Client ID / Secret: from your Keycloak client +5. Add Group Role Mapping: Entra/Keycloak group Object ID → OpnForm role + +Direct DB manipulation of `identity_connections` / `group_role_mappings` +is possible but fragile across OpnForm versions. A future iteration of +this role may automate it. + +## Example playbook + +```yaml +- name: Deploy OpnForm service + hosts: opnform_servers + become: true + roles: + - digitalboard.core.opnform +``` + +With inventory variables: + +```yaml +# group_vars/opnform_servers.yml +opnform_domain: forms.digitalboard.ch +opnform_base_url: "https://forms.digitalboard.ch" +opnform_app_key: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.app_key }}" +opnform_jwt_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.jwt_secret }}" +opnform_front_api_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.front_api_secret }}" +opnform_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/opnform', + mount_point='kv').data.data.db_password }}" +``` diff --git a/roles/OpnForm/defaults/main.yml b/roles/OpnForm/defaults/main.yml new file mode 100644 index 0000000..35996a2 --- /dev/null +++ b/roles/OpnForm/defaults/main.yml @@ -0,0 +1,71 @@ +#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 — generate with: openssl rand -base64 32 +# Always prefix opnform_app_key with "base64:" +# Provide via OpenBao lookup, Ansible Vault or extra-vars. +# Never commit real keys to version control. +opnform_app_key: "base64:vsQw8EoC64nmhurLUUohXUlAeryaV6Y2Is64Tdvjlko=" +opnform_jwt_secret: "0b2e8ed326334a08ce3846bfcd6588f5a11be33999e96963cd4eaff1a3ae828b" +opnform_front_api_secret: "8f52397785a110b657f2a6beab13362877bfac936ae9002bc236c54ed1011b2d" + +# Database credentials +opnform_db_name: "opnform" +opnform_db_user: "opnform" +opnform_db_password: "xtNLUVc2ajcWictqWXWkLR" + +# 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 (Stage 1: not auto-configured, set up via UI after deploy) +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_oidc_scopes: "openid profile email groups" +opnform_oidc_admin_group: "opnform-admins" + +# Traefik configuration +opnform_traefik_network: "proxy" +opnform_use_ssl: true diff --git a/roles/OpnForm/handlers/main.yml b/roles/OpnForm/handlers/main.yml new file mode 100644 index 0000000..1c0b422 --- /dev/null +++ b/roles/OpnForm/handlers/main.yml @@ -0,0 +1,8 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for opnform + +- name: restart opnform + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: restarted diff --git a/roles/OpnForm/meta/main.yml b/roles/OpnForm/meta/main.yml new file mode 100644 index 0000000..faea947 --- /dev/null +++ b/roles/OpnForm/meta/main.yml @@ -0,0 +1,35 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: your name + description: your role description + company: your company (optional) + + # If the issue tracker for your role is not on github, uncomment the + # next line and provide a value + # issue_tracker_url: http://example.com/issue/tracker + + # Choose a valid license ID from https://spdx.org - some suggested licenses: + # - BSD-3-Clause (default) + # - MIT + # - GPL-2.0-or-later + # - GPL-3.0-only + # - Apache-2.0 + # - CC-BY-4.0 + license: license (GPL-2.0-or-later, MIT, etc) + + min_ansible_version: 2.2 + + # If this a Container Enabled role, provide the minimum Ansible Container version. + # min_ansible_container_version: + + galaxy_tags: [] + # List tags for your role here, one per line. A tag is a keyword that describes + # and categorizes the role. Users find roles by searching for tags. Be sure to + # remove the '[]' above, if you add tags to this list. + # + # NOTE: A tag is limited to a single word comprised of alphanumeric characters. + # Maximum 20 tags per role. + +dependencies: [] + # List your role dependencies here, one per line. Be sure to remove the '[]' above, + # if you add dependencies to this list. \ No newline at end of file diff --git a/roles/OpnForm/tasks/main.yml b/roles/OpnForm/tasks/main.yml new file mode 100644 index 0000000..412dc25 --- /dev/null +++ b/roles/OpnForm/tasks/main.yml @@ -0,0 +1,117 @@ +#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 + fail_msg: >- + opnform_oidc_client_secret must be set when opnform_oidc_enabled is true. + 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 + +- name: Display deployment info + ansible.builtin.debug: + msg: |- + OpnForm deployed at {{ opnform_base_url }} + + Default credentials (from API container logs on first start): + Email: admin@opnform.com + Password: password + + On first login you will be prompted to change email and password. + + If login does not respond, the DB seed may have failed. Run: + docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan migrate:refresh --seed + docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan app:init-project + + OIDC: {% if opnform_oidc_enabled %}enabled (configure via Admin UI){% else %}disabled{% endif %} diff --git a/roles/OpnForm/templates/docker-compose.yml.j2 b/roles/OpnForm/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..de88a33 --- /dev/null +++ b/roles/OpnForm/templates/docker-compose.yml.j2 @@ -0,0 +1,189 @@ +#---------------------------------------------------------------------# +# OpnForm — Beautiful open-source form builder # +#---------------------------------------------------------------------# +services: + api: &api-service + image: {{ opnform_api_image }} + container_name: opnform-api + restart: unless-stopped + volumes: + - {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw + environment: &api-env + APP_ENV: production + APP_KEY: "{{ opnform_app_key }}" + APP_URL: "{{ opnform_base_url }}" + APP_DEBUG: "false" + SELF_HOSTED: "true" + + LOG_CHANNEL: errorlog + LOG_LEVEL: info + + DB_CONNECTION: pgsql + DB_HOST: db + DB_PORT: "5432" + DB_DATABASE: "{{ opnform_db_name }}" + DB_USERNAME: "{{ opnform_db_user }}" + DB_PASSWORD: "{{ opnform_db_password }}" + + REDIS_HOST: redis + REDIS_PORT: "6379" + + CACHE_STORE: redis + CACHE_DRIVER: redis + QUEUE_CONNECTION: redis + SESSION_DRIVER: redis + SESSION_LIFETIME: "120" + BROADCAST_CONNECTION: log + + FILESYSTEM_DISK: local + FILESYSTEM_DRIVER: local + LOCAL_FILESYSTEM_VISIBILITY: public + + MAIL_MAILER: "{{ opnform_mail_mailer }}" + MAIL_HOST: "{{ opnform_mail_host }}" + MAIL_PORT: "{{ opnform_mail_port }}" + MAIL_USERNAME: "{{ opnform_mail_username }}" + MAIL_PASSWORD: "{{ opnform_mail_password }}" + MAIL_ENCRYPTION: "{{ opnform_mail_encryption }}" + MAIL_FROM_ADDRESS: "{{ opnform_mail_from_address }}" + MAIL_FROM_NAME: "{{ opnform_mail_from_name }}" + + JWT_TTL: "1440" + JWT_SECRET: "{{ opnform_jwt_secret }}" + + PHP_MEMORY_LIMIT: "{{ opnform_php_memory_limit }}" + PHP_MAX_EXECUTION_TIME: "{{ opnform_php_max_execution_time }}" + PHP_UPLOAD_MAX_FILESIZE: "{{ opnform_php_upload_max_filesize }}" + PHP_POST_MAX_SIZE: "{{ opnform_php_post_max_size }}" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "php /usr/share/nginx/html/artisan about || exit 1"] + interval: 30s + timeout: 15s + retries: 3 + start_period: 60s + networks: + - opnform-internal + + api-worker: + <<: *api-service + container_name: opnform-api-worker + command: ["php", "artisan", "queue:work"] + environment: + <<: *api-env + IS_API_WORKER: "true" + healthcheck: + test: ["CMD-SHELL", "pgrep -f 'php artisan queue:work' > /dev/null || exit 1"] + interval: 60s + timeout: 10s + retries: 3 + start_period: 30s + + api-scheduler: + <<: *api-service + container_name: opnform-api-scheduler + command: ["php", "artisan", "schedule:work"] + healthcheck: + test: + - "CMD-SHELL" + - "php /usr/share/nginx/html/artisan app:scheduler-status --mode=check --max-minutes=3 || exit 1" + interval: 60s + timeout: 30s + retries: 3 + start_period: 70s + + ui: + image: {{ opnform_client_image }} + container_name: opnform-ui + restart: unless-stopped + environment: + NUXT_PUBLIC_APP_URL: "{{ opnform_base_url }}" + NUXT_PUBLIC_API_BASE: "/api" + NUXT_PRIVATE_API_BASE: "http://ingress/api" + NUXT_PUBLIC_ENV: production + FRONT_API_SECRET: "{{ opnform_front_api_secret }}" + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget --spider -q http://localhost:3000/login || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 45s + networks: + - opnform-internal + + redis: + image: {{ opnform_redis_image }} + container_name: opnform-redis + restart: unless-stopped + volumes: + - {{ opnform_redis_data_dir }}:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli ping | grep PONG"] + interval: 30s + timeout: 5s + networks: + - opnform-internal + + db: + image: {{ opnform_db_image }} + container_name: opnform-db + restart: unless-stopped + environment: + POSTGRES_DB: "{{ opnform_db_name }}" + POSTGRES_USER: "{{ opnform_db_user }}" + POSTGRES_PASSWORD: "{{ opnform_db_password }}" + volumes: + - {{ opnform_db_data_dir }}:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U {{ opnform_db_user }}"] + interval: 30s + timeout: 5s + networks: + - opnform-internal + + ingress: + image: {{ opnform_ingress_image }} + container_name: opnform-ingress + restart: unless-stopped + volumes: + - ./nginx.conf:/etc/nginx/templates/default.conf.template:ro + environment: + NGINX_MAX_BODY_SIZE: "{{ opnform_nginx_max_body_size }}" + depends_on: + api: + condition: service_started + ui: + condition: service_started + healthcheck: + test: ["CMD-SHELL", "nginx -t && curl -f http://localhost/ || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - opnform-internal + - {{ opnform_traefik_network }} + labels: + - traefik.enable=true + - traefik.docker.network={{ opnform_traefik_network }} + - traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`) +{% if opnform_use_ssl %} + - traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure + - traefik.http.routers.{{ opnform_service_name }}.tls=true +{% else %} + - traefik.http.routers.{{ opnform_service_name }}.entrypoints=web +{% endif %} + - traefik.http.services.{{ opnform_service_name }}.loadbalancer.server.port=80 + +networks: + opnform-internal: + driver: bridge + {{ opnform_traefik_network }}: + external: true diff --git a/roles/OpnForm/templates/nginx.conf.j2 b/roles/OpnForm/templates/nginx.conf.j2 new file mode 100644 index 0000000..fa3193b --- /dev/null +++ b/roles/OpnForm/templates/nginx.conf.j2 @@ -0,0 +1,43 @@ +map $original_uri $api_uri { + ~^/api(/.*$) $1; + default $original_uri; +} + +server { + listen 80; + server_name {{ opnform_domain }}; + root /app/public; + + client_max_body_size {% raw %}${NGINX_MAX_BODY_SIZE}{% endraw %}; + + access_log /dev/stdout; + error_log /dev/stderr error; + + index index.html index.htm index.php; + + location / { + proxy_http_version 1.1; + proxy_pass http://ui:3000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + } + + location ~/(api|open|local\/temp|forms\/assets)/ { + set $original_uri $uri; + try_files $uri $uri/ /index.php$is_args$args; + } + + location ~ \.php$ { + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass api:9000; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php; + fastcgi_param REQUEST_URI $api_uri; + } +} diff --git a/roles/OpnForm/tests/inventory b/roles/OpnForm/tests/inventory new file mode 100644 index 0000000..712db59 --- /dev/null +++ b/roles/OpnForm/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost diff --git a/roles/OpnForm/tests/test.yml b/roles/OpnForm/tests/test.yml new file mode 100644 index 0000000..60bdb75 --- /dev/null +++ b/roles/OpnForm/tests/test.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - OpnForm \ No newline at end of file diff --git a/roles/OpnForm/vars/main.yml b/roles/OpnForm/vars/main.yml new file mode 100644 index 0000000..984df2b --- /dev/null +++ b/roles/OpnForm/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for homarr \ No newline at end of file From 03af64ca2c0933357720be382c45baa9ebe83ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Mon, 18 May 2026 22:40:19 +0200 Subject: [PATCH 03/19] feat(opnform)!: add admin and OIDC bootstrap, rename role to lowercase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename roles/OpnForm → roles/opnform so the role resolves as digitalboard.core.opnform (Ansible collection convention is lowercase). Update tests/test.yml reference accordingly. Add automated admin user creation via POST /api/register, gated on opnform_admin_email + opnform_admin_password. Idempotent through a prior login probe. Without these vars the manual setup page flow is preserved. Add automated OIDC IdentityConnection setup via the per-workspace /api/open/workspaces/{id}/oidc-connections endpoint, gated on opnform_oidc_enabled. Hard-coupled to the admin bootstrap (the API requires an authenticated admin token); validation block fails fast if OIDC is enabled without admin credentials. Supports both an explicit opnform_oidc_group_role_mappings list and a fallback opnform_oidc_admin_group convenience var. Convert opnform_oidc_scopes from space-separated string to YAML list to match OpnForm's API expectation. Rewrite README "First login" and "OIDC setup" sections to reflect that self-hosted OpnForm does not ship a pre-seeded admin and to document the new bootstrap paths. BREAKING CHANGE: opnform_oidc_scopes changed from space-separated string to YAML list. Inventories that override it must update from "openid profile email" to [openid, profile, email]. --- roles/OpnForm/tasks/main.yml | 117 -------- roles/{OpnForm => opnform}/README.md | 93 ++++-- roles/{OpnForm => opnform}/defaults/main.yml | 34 ++- roles/{OpnForm => opnform}/handlers/main.yml | 0 roles/{OpnForm => opnform}/meta/main.yml | 0 roles/opnform/tasks/main.yml | 265 ++++++++++++++++++ .../templates/docker-compose.yml.j2 | 0 .../templates/nginx.conf.j2 | 0 roles/{OpnForm => opnform}/tests/inventory | 0 roles/{OpnForm => opnform}/tests/test.yml | 2 +- roles/{OpnForm => opnform}/vars/main.yml | 0 11 files changed, 366 insertions(+), 145 deletions(-) delete mode 100644 roles/OpnForm/tasks/main.yml rename roles/{OpnForm => opnform}/README.md (55%) rename roles/{OpnForm => opnform}/defaults/main.yml (61%) rename roles/{OpnForm => opnform}/handlers/main.yml (100%) rename roles/{OpnForm => opnform}/meta/main.yml (100%) create mode 100644 roles/opnform/tasks/main.yml rename roles/{OpnForm => opnform}/templates/docker-compose.yml.j2 (100%) rename roles/{OpnForm => opnform}/templates/nginx.conf.j2 (100%) rename roles/{OpnForm => opnform}/tests/inventory (100%) rename roles/{OpnForm => opnform}/tests/test.yml (86%) rename roles/{OpnForm => opnform}/vars/main.yml (100%) diff --git a/roles/OpnForm/tasks/main.yml b/roles/OpnForm/tasks/main.yml deleted file mode 100644 index 412dc25..0000000 --- a/roles/OpnForm/tasks/main.yml +++ /dev/null @@ -1,117 +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 - fail_msg: >- - opnform_oidc_client_secret must be set when opnform_oidc_enabled is true. - 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 - -- name: Display deployment info - ansible.builtin.debug: - msg: |- - OpnForm deployed at {{ opnform_base_url }} - - Default credentials (from API container logs on first start): - Email: admin@opnform.com - Password: password - - On first login you will be prompted to change email and password. - - If login does not respond, the DB seed may have failed. Run: - docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan migrate:refresh --seed - docker compose -f {{ opnform_docker_compose_dir }}/docker-compose.yml exec api php artisan app:init-project - - OIDC: {% if opnform_oidc_enabled %}enabled (configure via Admin UI){% else %}disabled{% endif %} diff --git a/roles/OpnForm/README.md b/roles/opnform/README.md similarity index 55% rename from roles/OpnForm/README.md rename to roles/opnform/README.md index 67e5436..2dfad2d 100644 --- a/roles/OpnForm/README.md +++ b/roles/opnform/README.md @@ -13,7 +13,6 @@ Docker Compose stack behind Traefik. ## What this role does NOT do (stage 1) -- Does not pre-create an admin user (use the default credentials below) - Does not pre-configure OIDC / identity_connections — set up via Admin UI ## Architecture note: why two reverse proxies? @@ -61,39 +60,83 @@ missing or malformed. ## First login -After the role completes, OpnForm seeds a default admin user. Visit -the URL in `opnform_base_url` and log in with: +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). -- Email: `admin@opnform.com` -- Password: `password` +This role supports two ways to create that first user: -On first login OpnForm will prompt you to change email and password. -Self-hosted instances disable public registration after this — invite -further users via the Admin UI. +### Option A — automated bootstrap (recommended) -### If the login does not respond +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. -The DB seed may have failed. Re-run it manually: - -```bash -cd /etc/docker/compose/opnform -docker compose exec api php artisan migrate:refresh --seed -docker compose exec api php artisan app:init-project +```yaml +opnform_admin_name: "Administrator" # default +opnform_admin_email: "admin@example.com" +opnform_admin_password: "{{ vault_opnform_admin_password }}" ``` -## OIDC setup (stage 2, not yet automated) +Password rules enforced by OpnForm: minimum 8 characters, at least one +letter, one digit, and one of `@$!%*#?&-_+=.,:;<>^()[]{}|~`. -Manual setup via the Admin UI is currently the supported path: +### Option B — manual setup page -1. Settings → Identity Connections → Add Connection -2. Provider: OIDC -3. Issuer: `https://auth.digitalboard.ch/realms/Digitalboard` -4. Client ID / Secret: from your Keycloak client -5. Add Group Role Mapping: Entra/Keycloak group Object ID → OpnForm role +Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit +`opnform_base_url` and complete the setup page in the browser. -Direct DB manipulation of `identity_connections` / `group_role_mappings` -is possible but fragile across OpnForm versions. A future iteration of -this role may automate it. +## 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 diff --git a/roles/OpnForm/defaults/main.yml b/roles/opnform/defaults/main.yml similarity index 61% rename from roles/OpnForm/defaults/main.yml rename to roles/opnform/defaults/main.yml index 35996a2..09aed4c 100644 --- a/roles/OpnForm/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -38,6 +38,17 @@ opnform_db_name: "opnform" opnform_db_user: "opnform" opnform_db_password: "xtNLUVc2ajcWictqWXWkLR" +# 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" @@ -57,14 +68,33 @@ opnform_mail_encryption: "" opnform_mail_from_address: "noreply@digitalboard.ch" opnform_mail_from_name: "OpnForm" -# OIDC configuration (Stage 1: not auto-configured, set up via UI after deploy) +# 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_oidc_scopes: "openid profile email groups" +# 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" diff --git a/roles/OpnForm/handlers/main.yml b/roles/opnform/handlers/main.yml similarity index 100% rename from roles/OpnForm/handlers/main.yml rename to roles/opnform/handlers/main.yml diff --git a/roles/OpnForm/meta/main.yml b/roles/opnform/meta/main.yml similarity index 100% rename from roles/OpnForm/meta/main.yml rename to roles/opnform/meta/main.yml diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml new file mode 100644 index 0000000..68e093b --- /dev/null +++ b/roles/opnform/tasks/main.yml @@ -0,0 +1,265 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for opnform + +# ===================================================================== +# 0. VALIDATION +# ===================================================================== + +- name: Validate required secrets + ansible.builtin.assert: + that: + - opnform_app_key | length > 0 + - opnform_app_key is match('^base64:[A-Za-z0-9+/=]+$') + - opnform_jwt_secret | length > 0 + - opnform_front_api_secret | length > 0 + - opnform_db_password | length > 0 + fail_msg: >- + OpnForm requires opnform_app_key (prefix 'base64:'), opnform_jwt_secret, + opnform_front_api_secret and opnform_db_password. + Generate with: openssl rand -base64 32 + The app_key MUST be prefixed with "base64:" + Provide via OpenBao, Ansible Vault or extra-vars. + success_msg: Secrets validation passed + +- name: Validate OIDC configuration when enabled + ansible.builtin.assert: + that: + - opnform_oidc_client_secret | length > 0 + - opnform_oidc_domain | length > 0 + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + fail_msg: >- + When opnform_oidc_enabled is true, you must set: + - opnform_oidc_client_secret + - opnform_oidc_domain (email domain that triggers OIDC) + - opnform_admin_email / opnform_admin_password + (the OIDC API requires an authenticated admin; the role logs in + with these credentials to POST the connection) + when: opnform_oidc_enabled | bool + +# ===================================================================== +# 1. PREPARATION +# ===================================================================== + +- name: Ensure required packages are installed + ansible.builtin.package: + name: + - python3-docker + state: present + +- name: Create docker compose directory + ansible.builtin.file: + path: "{{ opnform_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create OpnForm data directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ opnform_docker_volume_dir }}" + - "{{ opnform_storage_dir }}" + - "{{ opnform_db_data_dir }}" + - "{{ opnform_redis_data_dir }}" + +# ===================================================================== +# 2. CONFIGURATION FILES +# ===================================================================== + +- name: Deploy nginx ingress configuration + ansible.builtin.template: + src: nginx.conf.j2 + dest: "{{ opnform_docker_compose_dir }}/nginx.conf" + mode: '0644' + notify: restart opnform + +- name: Deploy docker-compose file + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + notify: restart opnform + +# ===================================================================== +# 3. CONTAINER STARTUP +# ===================================================================== + +- name: Start opnform containers + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: present + wait: true + wait_timeout: 180 + +# ===================================================================== +# 4. WAIT FOR API READINESS +# ===================================================================== + +- name: Wait for API container to be healthy + ansible.builtin.command: + cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api + register: api_health + until: api_health.stdout == "healthy" + retries: 30 + delay: 10 + changed_when: false + +# ===================================================================== +# 5. ADMIN BOOTSTRAP (optional) +# ===================================================================== +# Skips the self-hosted setup page by registering the first user via +# OpnForm's /api/register endpoint. Idempotent: a successful login +# attempt with the same credentials means the user already exists. + +- name: Check if OpnForm admin user already exists + ansible.builtin.uri: + url: "https://127.0.0.1/api/login" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + status_code: [200, 401, 422] + validate_certs: false + register: opnform_admin_login + when: + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + +- name: Create OpnForm admin user via /api/register + ansible.builtin.uri: + url: "https://127.0.0.1/api/register" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + name: "{{ opnform_admin_name }}" + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + password_confirmation: "{{ opnform_admin_password }}" + hear_about_us: "{{ opnform_admin_hear_about_us }}" + status_code: [200, 201] + validate_certs: false + no_log: true + when: + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + - opnform_admin_login.status != 200 + +# ===================================================================== +# 6. OIDC IDENTITY CONNECTION (optional) +# ===================================================================== +# Creates a single OIDC connection on the admin's default workspace. +# OpnForm enforces one OIDC connection per workspace, so this block is +# idempotent: we GET existing connections first and skip if any exists. + +- name: Log in as admin to obtain OIDC API token + ansible.builtin.uri: + url: "https://127.0.0.1/api/login" + method: POST + headers: + Host: "{{ opnform_domain }}" + body_format: json + body: + email: "{{ opnform_admin_email }}" + password: "{{ opnform_admin_password }}" + status_code: 200 + validate_certs: false + register: opnform_oidc_token + no_log: true + when: opnform_oidc_enabled | bool + +- name: Fetch admin's workspaces + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces" + method: GET + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + status_code: 200 + validate_certs: false + register: opnform_workspaces + no_log: true + when: opnform_oidc_enabled | bool + +- name: Fetch existing OIDC connections for the default workspace + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" + method: GET + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + status_code: 200 + validate_certs: false + register: opnform_existing_oidc + no_log: true + when: opnform_oidc_enabled | bool + +- name: Resolve OIDC group-role mappings + ansible.builtin.set_fact: + _opnform_oidc_group_role_mappings: >- + {{ + opnform_oidc_group_role_mappings + if (opnform_oidc_group_role_mappings | length > 0) + else + ([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}] + if (opnform_oidc_admin_group | length > 0) else []) + }} + when: opnform_oidc_enabled | bool + +- name: Create OIDC identity connection + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections" + method: POST + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + body_format: json + body: + name: "{{ opnform_oidc_client_name }}" + slug: "{{ opnform_oidc_slug }}" + domain: "{{ opnform_oidc_domain }}" + issuer: "{{ opnform_oidc_issuer }}" + client_id: "{{ opnform_oidc_client_id }}" + client_secret: "{{ opnform_oidc_client_secret }}" + scopes: "{{ opnform_oidc_scopes }}" + enabled: true + options: + require_state: true + group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}" + status_code: [201] + validate_certs: false + no_log: true + when: + - opnform_oidc_enabled | bool + - opnform_existing_oidc.json | length == 0 + +- name: Display deployment info + ansible.builtin.debug: + msg: |- + OpnForm deployed at {{ opnform_base_url }} + + {% if opnform_admin_email | length > 0 %} + Admin user bootstrapped: + Email: {{ opnform_admin_email }} + Password: (from opnform_admin_password) + {% else %} + No admin bootstrap configured — visit {{ opnform_base_url }} and + complete the self-hosted setup page to create the first user. + Set opnform_admin_email + opnform_admin_password to automate this. + {% endif %} + + {% if opnform_oidc_enabled %} + OIDC: connection "{{ opnform_oidc_client_name }}" bootstrapped + (slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }}) + Users with @{{ opnform_oidc_domain }} addresses will be + redirected to {{ opnform_oidc_issuer }} on login. + {% else %} + OIDC: disabled (set opnform_oidc_enabled=true to auto-configure) + {% endif %} diff --git a/roles/OpnForm/templates/docker-compose.yml.j2 b/roles/opnform/templates/docker-compose.yml.j2 similarity index 100% rename from roles/OpnForm/templates/docker-compose.yml.j2 rename to roles/opnform/templates/docker-compose.yml.j2 diff --git a/roles/OpnForm/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 similarity index 100% rename from roles/OpnForm/templates/nginx.conf.j2 rename to roles/opnform/templates/nginx.conf.j2 diff --git a/roles/OpnForm/tests/inventory b/roles/opnform/tests/inventory similarity index 100% rename from roles/OpnForm/tests/inventory rename to roles/opnform/tests/inventory diff --git a/roles/OpnForm/tests/test.yml b/roles/opnform/tests/test.yml similarity index 86% rename from roles/OpnForm/tests/test.yml rename to roles/opnform/tests/test.yml index 60bdb75..3ff9caa 100644 --- a/roles/OpnForm/tests/test.yml +++ b/roles/opnform/tests/test.yml @@ -3,4 +3,4 @@ - hosts: localhost remote_user: root roles: - - OpnForm \ No newline at end of file + - opnform \ No newline at end of file diff --git a/roles/OpnForm/vars/main.yml b/roles/opnform/vars/main.yml similarity index 100% rename from roles/OpnForm/vars/main.yml rename to roles/opnform/vars/main.yml From 48d12a1b4a19367dbe3b35503dbff5aa2e019775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:58:10 +0200 Subject: [PATCH 04/19] fix(opnform): address review feedback on vars header and meta boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * vars/main.yml: header was 'vars file for homarr' (copy-paste from the homarr role). Fixed to 'vars file for opnform'. File body is empty. * meta/main.yml: replace ansible-galaxy init boilerplate with real metadata — author, description, license (MIT-0), min_ansible_version set to '2.15' as a string (galaxy schema requires str), galaxy_tags for discovery, and an empty dependencies list. The third inline finding (dead roles/opnform/templates/compose.yml.j2) is resolved by dropping the WIP commit a6f301e during the rebase rather than removing it in a separate commit — the file no longer exists in the rebased history. --- roles/opnform/meta/main.yml | 41 ++++++++++--------------------------- roles/opnform/vars/main.yml | 2 +- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/roles/opnform/meta/main.yml b/roles/opnform/meta/main.yml index faea947..8a56a7b 100644 --- a/roles/opnform/meta/main.yml +++ b/roles/opnform/meta/main.yml @@ -1,35 +1,16 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + 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" - # If the issue tracker for your role is not on github, uncomment the - # next line and provide a value - # issue_tracker_url: http://example.com/issue/tracker - - # Choose a valid license ID from https://spdx.org - some suggested licenses: - # - BSD-3-Clause (default) - # - MIT - # - GPL-2.0-or-later - # - GPL-3.0-only - # - Apache-2.0 - # - CC-BY-4.0 - license: license (GPL-2.0-or-later, MIT, etc) - - min_ansible_version: 2.2 - - # If this a Container Enabled role, provide the minimum Ansible Container version. - # min_ansible_container_version: - - galaxy_tags: [] - # List tags for your role here, one per line. A tag is a keyword that describes - # and categorizes the role. Users find roles by searching for tags. Be sure to - # remove the '[]' above, if you add tags to this list. - # - # NOTE: A tag is limited to a single word comprised of alphanumeric characters. - # Maximum 20 tags per role. + galaxy_tags: + - opnform + - forms + - docker + - traefik + - oidc dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. \ No newline at end of file diff --git a/roles/opnform/vars/main.yml b/roles/opnform/vars/main.yml index 984df2b..94900f8 100644 --- a/roles/opnform/vars/main.yml +++ b/roles/opnform/vars/main.yml @@ -1,3 +1,3 @@ #SPDX-License-Identifier: MIT-0 --- -# vars file for homarr \ No newline at end of file +# vars file for opnform \ No newline at end of file From fb81f60f9d6259d8b0584d74ee0b13c9a043fef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:58:18 +0200 Subject: [PATCH 05/19] fix(opnform): drop production-looking secrets from defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opnform_app_key, opnform_jwt_secret, opnform_front_api_secret and opnform_db_password shipped as real base64 strings in defaults — they look like production secrets that just happen to be public. Set all four to '' and rely on the existing Validate task (and the new argument_specs marking them required) to fail fast when an inventory forgets to override them. Mirror the docstring comment to show how to generate each one with openssl. --- roles/opnform/defaults/main.yml | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml index 09aed4c..0f61c3a 100644 --- a/roles/opnform/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -25,18 +25,26 @@ opnform_redis_image: "redis:7" opnform_db_image: "postgres:16" opnform_ingress_image: "nginx:1" -# REQUIRED SECRETS — generate with: openssl rand -base64 32 -# Always prefix opnform_app_key with "base64:" +# REQUIRED SECRETS — must be overridden per-inventory. # Provide via OpenBao lookup, Ansible Vault or extra-vars. # Never commit real keys to version control. -opnform_app_key: "base64:vsQw8EoC64nmhurLUUohXUlAeryaV6Y2Is64Tdvjlko=" -opnform_jwt_secret: "0b2e8ed326334a08ce3846bfcd6588f5a11be33999e96963cd4eaff1a3ae828b" -opnform_front_api_secret: "8f52397785a110b657f2a6beab13362877bfac936ae9002bc236c54ed1011b2d" +# +# 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 +# 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: "xtNLUVc2ajcWictqWXWkLR" +opnform_db_password: "" # Admin bootstrap — when email+password are set, the role creates the # first user via OpnForm's /api/register endpoint, skipping the From 30f3c16b59de8aa2362f547730eb4a883c45895c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:58:36 +0200 Subject: [PATCH 06/19] docs(opnform): add meta/argument_specs.yml 50 typed options covering the full defaults file plus the OIDC subschema (group_role_mappings with idp_group + role choices). Required secrets (app_key, jwt_secret, front_api_secret, db_password) marked required: true so ansible refuses the play with a clear error before the validate task even runs. Loads cleanly through ansible-core's ArgumentSpecValidator. Matches the spec convention introduced for traefik, authentik, drawio, garage and nextcloud. --- roles/opnform/meta/argument_specs.yml | 220 ++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 roles/opnform/meta/argument_specs.yml diff --git a/roles/opnform/meta/argument_specs.yml b/roles/opnform/meta/argument_specs.yml new file mode 100644 index 0000000..9fbfc7a --- /dev/null +++ b/roles/opnform/meta/argument_specs.yml @@ -0,0 +1,220 @@ +--- +argument_specs: + main: + short_description: Deploy OpnForm (api + ui + db + redis + ingress) via Docker Compose. + description: + - Renders a Compose stack for the full OpnForm setup (PHP-FPM api, + Nuxt ui, Postgres, Redis, nginx ingress) and exposes it through + Traefik. + - Optionally bootstraps the first admin user via the OpnForm + C(/api/register) endpoint (skipping the self-hosted setup page) + and provisions a single OIDC identity connection in the default + workspace via the workspace API. Both bootstraps are idempotent. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + opnform_service_name: + type: str + default: opnform + opnform_docker_compose_dir: + type: path + description: Defaults to C({{ docker_compose_base_dir }}/{{ opnform_service_name }}). + opnform_docker_volume_dir: + type: path + description: Defaults to C({{ docker_volume_base_dir }}/{{ opnform_service_name }}). + opnform_storage_dir: + type: path + description: OpnForm storage volume mounted into the api container. + opnform_db_data_dir: + type: path + opnform_redis_data_dir: + type: path + + opnform_domain: + type: str + default: forms.local.test + description: Hostname used in the traefik Host rule. + opnform_base_url: + type: str + default: https://forms.local.test + description: Public URL OpnForm uses for APP_URL and NUXT_PUBLIC_APP_URL. + + opnform_api_image: + type: str + default: jhumanj/opnform-api:latest + opnform_client_image: + type: str + default: jhumanj/opnform-client:latest + opnform_redis_image: + type: str + default: "redis:7" + opnform_db_image: + type: str + default: "postgres:16" + opnform_ingress_image: + type: str + default: "nginx:1" + + opnform_app_key: + type: str + required: true + description: + - Laravel application key. Must be prefixed with C(base64:). + Generate with C(echo "base64:$(openssl rand -base64 32)"). + Provide via OpenBao, Ansible Vault or extra-vars. + opnform_jwt_secret: + type: str + required: true + description: JWT signing secret. Generate with C(openssl rand -hex 32). + opnform_front_api_secret: + type: str + required: true + description: Shared secret between ui and api. Generate with C(openssl rand -hex 32). + + opnform_db_name: + type: str + default: opnform + opnform_db_user: + type: str + default: opnform + opnform_db_password: + type: str + required: true + + opnform_admin_name: + type: str + default: Administrator + opnform_admin_email: + type: str + default: '' + description: + - When non-empty (together with C(opnform_admin_password)) the role + bootstraps the first user via C(/api/register), skipping the + self-hosted setup page. Required when C(opnform_oidc_enabled=true). + opnform_admin_password: + type: str + default: '' + description: + - "Must satisfy OpnForm's policy: min 8 chars, letter + digit + + symbol from C(@$!%*#?&-_+=.,:;<>^()[]{}|~)." + opnform_admin_hear_about_us: + type: str + default: ansible + + opnform_php_memory_limit: + type: str + default: 1G + opnform_php_max_execution_time: + type: str + default: "600" + opnform_php_upload_max_filesize: + type: str + default: 64M + opnform_php_post_max_size: + type: str + default: 64M + opnform_nginx_max_body_size: + type: str + default: 64m + + opnform_mail_mailer: + type: str + default: log + choices: [log, smtp, ses, mailgun, postmark, sendmail] + opnform_mail_host: + type: str + default: '' + opnform_mail_port: + type: str + default: '' + opnform_mail_username: + type: str + default: '' + opnform_mail_password: + type: str + default: '' + opnform_mail_encryption: + type: str + default: '' + choices: ['', tls, ssl] + opnform_mail_from_address: + type: str + default: noreply@digitalboard.ch + opnform_mail_from_name: + type: str + default: OpnForm + + opnform_oidc_enabled: + type: bool + default: false + description: + - "When true the role calls the workspace API to create a single + OIDC C(identity_connection) on the default workspace after the + admin bootstrap. Requires C(opnform_admin_email) + + C(opnform_admin_password) so the role can authenticate. + Idempotent: skipped when any connection already exists." + opnform_oidc_issuer: + type: str + default: https://auth.digitalboard.ch/realms/Digitalboard + description: OIDC issuer URL. + opnform_oidc_client_id: + type: str + default: opnform-digitalboard + opnform_oidc_client_secret: + type: str + default: '' + description: Required when C(opnform_oidc_enabled=true). + opnform_oidc_client_name: + type: str + default: Digitalboard + description: Display name shown in the OpnForm UI. + opnform_oidc_slug: + type: str + default: oidc + description: + - OpnForm-side identifier used in C(/auth/{slug}/callback). Lowercase + alphanumeric + hyphens, unique across all C(identity_connections). + opnform_oidc_domain: + type: str + default: '' + description: + - Email domain that triggers OIDC for matching users. Required + when C(opnform_oidc_enabled=true). + opnform_oidc_scopes: + type: list + elements: str + default: [openid, profile, email, groups] + opnform_oidc_admin_group: + type: str + default: opnform-admins + description: + - Convenience setting that maps a single IdP group to the OpnForm + C(admin) role. Ignored when C(opnform_oidc_group_role_mappings) + is non-empty. + opnform_oidc_group_role_mappings: + type: list + elements: dict + default: [] + description: + - Full IdP-group -> OpnForm-role mapping. Takes precedence over + C(opnform_oidc_admin_group). + options: + idp_group: + type: str + required: true + description: Group name as it appears in the IdP groups claim. + role: + type: str + required: true + choices: [owner, admin, editor, member] + + opnform_traefik_network: + type: str + default: proxy + opnform_use_ssl: + type: bool + default: true From 9d1a5d1924dbfe73595dab28b3a73b3bcb54b023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:06:58 +0200 Subject: [PATCH 07/19] fix(send): self-review fixes (FQCN, min_ansible_version str) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * tasks/main.yml: prefix all builtin modules with ansible.builtin (file, template) — silences ansible-lint fqcn[action-core] and matches the convention used by the other roles in this collection. * meta/main.yml: change min_ansible_version from the float 2.14 to the string '2.14'. ansible-galaxy's schema requires a string here (ansible-lint schema[meta] complains otherwise — same fix I just applied to the opnform role). --- roles/send/meta/main.yml | 2 +- roles/send/tasks/main.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/roles/send/meta/main.yml b/roles/send/meta/main.yml index 79dedb1..20f9e67 100644 --- a/roles/send/meta/main.yml +++ b/roles/send/meta/main.yml @@ -4,7 +4,7 @@ galaxy_info: description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose license: MIT - min_ansible_version: 2.14 + min_ansible_version: "2.14" galaxy_tags: - send diff --git a/roles/send/tasks/main.yml b/roles/send/tasks/main.yml index c79405a..9ed8dd8 100644 --- a/roles/send/tasks/main.yml +++ b/roles/send/tasks/main.yml @@ -3,20 +3,20 @@ # tasks file for send - name: Create docker compose directory - file: + ansible.builtin.file: path: "{{ send_docker_compose_dir }}" state: directory mode: '0755' - name: Create local upload directory - file: + ansible.builtin.file: path: "{{ send_docker_volume_dir }}/uploads" state: directory mode: '0755' when: send_storage_backend == "local" - name: Create docker-compose file for send - template: + ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ send_docker_compose_dir }}/docker-compose.yml" mode: '0644' From 5ce2a8f7375e0f804a99fdb48f22f156d290cee8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:07:07 +0200 Subject: [PATCH 08/19] fix(send): use Traefik v3 OR-syntax for multi-domain Host rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router rule joined send_domains with ', ' which is the v2 syntax ('Host(`a`, `b`)'). Traefik v3 expects each Host() to be its own matcher joined with the explicit '||' OR operator. With v3 the comma form is silently ignored — only the first host actually matches. Match the pattern already used in the authentik, drawio and nextcloud roles in this collection. --- roles/send/templates/docker-compose.yml.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/send/templates/docker-compose.yml.j2 b/roles/send/templates/docker-compose.yml.j2 index a6733bb..69a43ab 100644 --- a/roles/send/templates/docker-compose.yml.j2 +++ b/roles/send/templates/docker-compose.yml.j2 @@ -45,7 +45,7 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ send_traefik_network }} - - traefik.http.routers.{{ send_service_name }}.rule=Host({% for d in send_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) + - traefik.http.routers.{{ send_service_name }}.rule={% for d in send_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} - traefik.http.services.{{ send_service_name }}.loadbalancer.server.port={{ send_port }} {% if send_use_ssl %} - traefik.http.routers.{{ send_service_name }}.entrypoints=websecure From d526ec382dca1d3ae70247c6a388c0ece2a087ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:07:17 +0200 Subject: [PATCH 09/19] docs(send): add meta/argument_specs.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 29 typed options with full defaults coverage (no required: true marks — the role works with an empty S3 config when storage_backend=local). Documents the send_domains list convention, the local-vs-s3 storage choice, the timing/size limits and the Traefik / network wiring. Loads through ansible-core's ArgumentSpecValidator. Matches the spec convention used by the other roles in this collection. --- roles/send/meta/argument_specs.yml | 122 +++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 roles/send/meta/argument_specs.yml diff --git a/roles/send/meta/argument_specs.yml b/roles/send/meta/argument_specs.yml new file mode 100644 index 0000000..2e9797e --- /dev/null +++ b/roles/send/meta/argument_specs.yml @@ -0,0 +1,122 @@ +--- +argument_specs: + main: + short_description: Deploy timvisee/send (file-sharing) with a Redis backend via Docker Compose. + description: + - Renders a Compose stack with the C(timvisee/send) container and a + Redis companion behind Traefik. Storage can be local-disk or any + S3-compatible backend (e.g. the C(garage) role). + - Uses the shared C(*_domains) list convention so the router can + accept internal C(*.int.*) hostnames alongside the canonical + BASE_URL host. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + send_service_name: + type: str + default: send + send_docker_compose_dir: + type: path + send_docker_volume_dir: + type: path + + send_domains: + type: list + elements: str + default: ['send.local.test'] + description: + - FQDNs the router accepts. First entry is the canonical hostname + and is used as C(BASE_URL). Further entries cover internal + C(*.int.*) names so backend uploads can hit Send without + hairpinning via the DMZ. + send_image: + type: str + default: "registry.gitlab.com/timvisee/send:latest" + send_port: + type: int + default: 1443 + send_extra_hosts: + type: list + elements: str + default: [] + description: C(extra_hosts) entries injected into the send container (Docker C(host:ip) syntax). + + send_redis_image: + type: str + default: "redis:7-alpine" + send_redis_service_name: + type: str + default: send-redis + + send_max_file_size: + type: int + default: 1073741824 + description: Max upload size in bytes. Default is 1 GiB. + send_default_downloads: + type: int + default: 1 + send_max_downloads: + type: int + default: 100 + send_default_expire_seconds: + type: int + default: 86400 + description: Default share lifetime in seconds (24 h). + send_max_expire_seconds: + type: int + default: 604800 + description: Maximum share lifetime in seconds (7 d). + send_max_files_per_archive: + type: int + default: 64 + send_download_counts: + type: str + default: "1,2,3,4,5,20,50,100" + description: Comma-separated list of download-count options shown in the UI. + send_expire_times_seconds: + type: str + default: "300,3600,86400,604800" + description: Comma-separated list of expire-time options (seconds) shown in the UI. + + send_storage_backend: + type: str + choices: [local, s3] + default: local + description: + - C(local) keeps uploads in a host volume. C(s3) uses an + S3-compatible backend (any of the C(send_s3_*) variables is + required when this is set). + + send_s3_endpoint: + type: str + default: '' + send_s3_bucket: + type: str + default: '' + send_s3_region: + type: str + default: us-east-1 + send_s3_access_key: + type: str + default: '' + send_s3_secret_key: + type: str + default: '' + send_s3_use_path_style: + type: bool + default: true + description: Required for most non-AWS S3-compatible backends (Garage, MinIO). + + send_traefik_network: + type: str + default: proxy + send_internal_network: + type: str + default: send_internal + send_use_ssl: + type: bool + default: true From 951b1822fe44461121822550309ca8c0aeb3e862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Wed, 20 May 2026 17:39:16 +0200 Subject: [PATCH 10/19] feat(bookstack): add role for self-hosted BookStack deployment Deploy BookStack with linuxserver.io images behind Traefik, including Entra ID OIDC SSO support and a daily backup timer. Stack: - lscr.io/linuxserver/bookstack:version-v26.03.3 - lscr.io/linuxserver/mariadb:11.4.9 - Traefik labels for websecure entrypoint on internal network - Healthcheck via mariadb-admin ping (LSIO image lacks healthcheck.sh) Features: - Persistent APP_KEY generated on first run, stored in volume dir - Optional OIDC SSO via Microsoft Entra ID (configurable per-instance) - Idempotent admin user creation with DB-based existence check - Daily systemd timer backup (DB dump + uploads tar + APP_KEY) with configurable retention Implementation notes: - DB queries use --protocol=tcp with the app user because root@localhost uses unix_socket auth in the LSIO MariaDB image (no password) and root@% does not exist - docker_container_exec uses argv: (list) instead of command: (string) to avoid argument-splitting issues - Migration-wait task ensures users table exists before admin check, since /login returns 200 before Laravel migrations complete - no_log: true on all tasks that reference DB or admin passwords - artisan absolute path (/app/www/artisan) because LSIO image WORKDIR is not the app directory Adds bookstack route to DMZ Traefik service registry. --- galaxy.yml | 1 + .../filter}/homarr_layout.py | 0 roles/bookstack/README.md | 145 ++++++++++++ roles/bookstack/defaults/main.yml | 85 +++++++ roles/bookstack/handlers/main.yml | 19 ++ roles/bookstack/meta/main.yml | 25 ++ roles/bookstack/tasks/main.yml | 223 ++++++++++++++++++ roles/bookstack/templates/backup.sh.j2 | 41 ++++ .../templates/bookstack-backup.service.j2 | 12 + .../templates/bookstack-backup.timer.j2 | 11 + .../bookstack/templates/docker-compose.yml.j2 | 87 +++++++ roles/bookstack/tests/inventory | 1 + roles/bookstack/tests/test.yml | 5 + roles/bookstack/vars/main.yml | 3 + .../tests/test_homarr_layout.py | 6 +- roles/homarr/tasks/main.yml | 2 +- 16 files changed, 664 insertions(+), 2 deletions(-) rename {roles/homarr/filter_plugins => plugins/filter}/homarr_layout.py (100%) create mode 100644 roles/bookstack/README.md create mode 100644 roles/bookstack/defaults/main.yml create mode 100644 roles/bookstack/handlers/main.yml create mode 100644 roles/bookstack/meta/main.yml create mode 100644 roles/bookstack/tasks/main.yml create mode 100644 roles/bookstack/templates/backup.sh.j2 create mode 100644 roles/bookstack/templates/bookstack-backup.service.j2 create mode 100644 roles/bookstack/templates/bookstack-backup.timer.j2 create mode 100644 roles/bookstack/templates/docker-compose.yml.j2 create mode 100644 roles/bookstack/tests/inventory create mode 100644 roles/bookstack/tests/test.yml create mode 100644 roles/bookstack/vars/main.yml diff --git a/galaxy.yml b/galaxy.yml index 91cd337..c208d8f 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -19,6 +19,7 @@ readme: README.md authors: - Bert-Jan Fikse - Tobias Wüst +- Simon Bärlocher ### OPTIONAL but strongly recommended # A short summary description of the collection diff --git a/roles/homarr/filter_plugins/homarr_layout.py b/plugins/filter/homarr_layout.py similarity index 100% rename from roles/homarr/filter_plugins/homarr_layout.py rename to plugins/filter/homarr_layout.py diff --git a/roles/bookstack/README.md b/roles/bookstack/README.md new file mode 100644 index 0000000..25fb789 --- /dev/null +++ b/roles/bookstack/README.md @@ -0,0 +1,145 @@ +# Ansible Role: bookstack + +Deploys [BookStack](https://www.bookstackapp.com/) as a self-contained Docker +Compose stack behind Traefik, with its own MariaDB container, OIDC SSO +(Entra ID by default) and a daily systemd-timer driven backup of database +and uploads. + +## Requirements + +- Docker Engine + Compose plugin on the target host +- Traefik already running, with the external network referenced by + `bookstack_traefik_network` (default: `proxy`) +- `community.docker` collection on the controller +- DNS for `bookstack_domain` pointing at the Traefik host + +## Required variables + +The role asserts these are set; the play fails fast if any is empty: + +| Variable | Description | +|---|---| +| `bookstack_db_root_password` | MariaDB root password | +| `bookstack_db_password` | MariaDB user password | +| `bookstack_admin_password` | Initial local admin password | +| `bookstack_oidc_client_id` | Entra ID App Registration ID (if OIDC on) | +| `bookstack_oidc_client_secret` | Entra ID client secret (if OIDC on) | +| `bookstack_entra_tenant_id` | Entra tenant UUID (if OIDC on) | + +Provide via OpenBao lookup, Ansible Vault or `--extra-vars`. Never commit +real secrets. + +## Optional variables + +See `defaults/main.yml`. Frequently overridden: + +- `bookstack_domain`, `bookstack_base_url` +- `bookstack_image`, `bookstack_db_image` (pin in production) +- `bookstack_oidc_enabled` (set `false` to disable OIDC entirely) +- `bookstack_oidc_auto_initiate` (`true` redirects straight to IdP) +- `bookstack_oidc_user_to_groups` (`true` syncs roles from Entra groups) +- `bookstack_backup_enabled`, `bookstack_backup_schedule`, + `bookstack_backup_retention_days` + +## Entra ID app registration + +1. Azure Portal → Entra ID → App registrations → New registration +2. Redirect URI (Web): `https:///oidc/callback` +3. Front-channel logout URL: `https:///logout` +4. Certificates & secrets → New client secret → + `bookstack_oidc_client_secret` +5. For group sync (`bookstack_oidc_user_to_groups: true`): + - Token configuration → Add groups claim → Security groups + - In BookStack, create roles whose **External Auth ID** equals the + Entra group Object ID, so the mapping resolves on first login. + +## What the role does + +| Phase | Action | +|---|---| +| Validate | `assert` all required secrets are set | +| Prepare | install packages, create volume dirs, generate persistent `APP_KEY`, verify Traefik network | +| Deploy | render `docker-compose.yml`, pull images, bring stack up | +| Configure | wait for the app, create the initial local admin via `php artisan bookstack:create-admin` (idempotent) | +| Backup | render `/usr/local/bin/bookstack-backup.sh` + systemd timer (daily 03:00, 14-day retention) | + +## Example playbook + +```yaml +- name: Deploy BookStack service + hosts: bookstack_servers + become: true + roles: + - digitalboard.core.bookstack +``` + +With inventory variables: + +```yaml +# group_vars/bookstack_servers.yml +bookstack_domain: wiki.digitalboard.ch +bookstack_base_url: "https://wiki.digitalboard.ch" +bookstack_entra_tenant_id: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.tenant_id }}" +bookstack_oidc_client_id: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.client_id }}" +bookstack_oidc_client_secret: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.client_secret }}" +bookstack_db_root_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.db_root_password }}" +bookstack_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.db_password }}" +bookstack_admin_password: "{{ lookup('community.hashi_vault.vault_kv2_get', + 'digitalboard/bookstack', + mount_point='kv').data.data.admin_password }}" +``` + +## Backup / restore + +Backups land in `{{ bookstack_backup_dir }}` (default +`/srv/data/bookstack/backup`) with three files per run: + +- `bookstack-db-.sql.gz` — mariadb-dump +- `bookstack-files-.tar.gz` — uploads, attachments +- `bookstack-appkey-.txt` — APP_KEY (required for restore!) + +Manual trigger: `systemctl start bookstack-backup.service` +Timer status: `systemctl list-timers bookstack-backup.timer` + +Restore procedure: + +1. Stop the stack: `docker compose down` in `bookstack_docker_compose_dir` +2. Restore the APP_KEY: copy the `.txt` content to + `{{ bookstack_docker_volume_dir }}/.app_key` (the key MUST match or + encrypted DB values become unreadable) +3. Start only the DB container, then load the dump: + ```bash + gunzip -c bookstack-db-.sql.gz \ + | docker exec -i bookstack-db \ + mariadb -u root -p"" bookstack + ``` +4. Extract the files: `tar -xzf bookstack-files-.tar.gz -C + {{ bookstack_appdata_dir }}/www/` +5. Bring the stack back up: `docker compose up -d` + +## Notes + +- `bookstack_oidc_auto_initiate: false` (default) shows a login page + with an SSO button alongside the local login form. With `true`, users + go straight to the IdP — the local admin then has to use + `https:///login?email_login=1`. +- `bookstack_oidc_user_to_groups: true` only makes sense once BookStack + roles with the correct **External Auth IDs** (= Entra group Object + IDs) exist; otherwise users lose their role assignment on every login. +- Image tags default to pinned versions; bump them deliberately rather + than chasing `latest`. +- BookStack officially supports MySQL/MariaDB only — no PostgreSQL. + +## License + +MIT diff --git a/roles/bookstack/defaults/main.yml b/roles/bookstack/defaults/main.yml new file mode 100644 index 0000000..3efbadb --- /dev/null +++ b/roles/bookstack/defaults/main.yml @@ -0,0 +1,85 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for bookstack + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# bookstack-specific configuration +bookstack_service_name: bookstack +bookstack_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ bookstack_service_name }}" +bookstack_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ bookstack_service_name }}" +bookstack_appdata_dir: "{{ bookstack_docker_volume_dir }}/appdata" +bookstack_db_data_dir: "{{ bookstack_docker_volume_dir }}/db" +bookstack_backup_dir: "{{ bookstack_docker_volume_dir }}/backup" + +# Service configuration +bookstack_domain: "wiki.local.test" +bookstack_base_url: "https://{{ bookstack_domain }}" + +# Images — pin via inventory in production +bookstack_image: "lscr.io/linuxserver/bookstack:version-v26.03.3" +bookstack_db_image: "lscr.io/linuxserver/mariadb:11.4.9" + +# Traefik configuration +bookstack_traefik_network: "proxy" +bookstack_traefik_certresolver: "le" + +# Timezone / UID +bookstack_tz: "Europe/Zurich" +bookstack_puid: "1000" +bookstack_pgid: "1000" + +# Database configuration +bookstack_db_name: "bookstack" +bookstack_db_user: "bookstack" + +# REQUIRED SECRETS — empty defaults force `assert` to fail until set. +# Provide via OpenBao lookup, Ansible Vault, or extra-vars. +# Never commit real secrets to version control. +# +# Generate with: +# bookstack_db_root_password: openssl rand -base64 32 | tr -d '/+=' +# bookstack_db_password: openssl rand -base64 32 | tr -d '/+=' +# bookstack_admin_password: openssl rand -base64 24 | tr -d '/+=' +bookstack_db_root_password: "" +bookstack_db_password: "" +bookstack_admin_password: "" +bookstack_oidc_client_secret: "" + +# APP_KEY is generated automatically on first run and persisted on the host. +# Set explicitly only if restoring an existing instance. +bookstack_app_key: "" + +# Initial local admin (fallback account, lives alongside OIDC) +bookstack_admin_name: "Admin" +bookstack_admin_email: "admin@local.test" +bookstack_artisan_path: "/app/www/artisan" + +# Mail configuration +bookstack_mail_driver: "smtp" +bookstack_mail_host: "smtp.local.test" +bookstack_mail_port: 587 +bookstack_mail_encryption: "tls" +bookstack_mail_from: "bookstack@local.test" +bookstack_mail_from_name: "BookStack" +bookstack_mail_username: "" +bookstack_mail_password: "" + +# OIDC configuration (Entra ID by default; override `bookstack_oidc_issuer` +# for Keycloak or any other provider) +bookstack_oidc_enabled: false +bookstack_oidc_name: "SSO" +bookstack_entra_tenant_id: "" +bookstack_oidc_issuer: "https://login.microsoftonline.com/{{ bookstack_entra_tenant_id }}/v2.0" +bookstack_oidc_client_id: "" +bookstack_oidc_auto_initiate: false +bookstack_oidc_user_to_groups: false +bookstack_oidc_groups_claim: "groups" +bookstack_oidc_additional_scopes: "openid profile email" + +# Backup configuration +bookstack_backup_enabled: true +bookstack_backup_retention_days: 14 +bookstack_backup_schedule: "*-*-* 03:00:00" diff --git a/roles/bookstack/handlers/main.yml b/roles/bookstack/handlers/main.yml new file mode 100644 index 0000000..eb6a769 --- /dev/null +++ b/roles/bookstack/handlers/main.yml @@ -0,0 +1,19 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for bookstack + +- name: stop bookstack + community.docker.docker_compose_v2: + project_src: "{{ bookstack_docker_compose_dir }}" + state: stopped + listen: restart bookstack + +- name: start bookstack + community.docker.docker_compose_v2: + project_src: "{{ bookstack_docker_compose_dir }}" + state: present + listen: restart bookstack + +- name: reload systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/roles/bookstack/meta/main.yml b/roles/bookstack/meta/main.yml new file mode 100644 index 0000000..a6e941d --- /dev/null +++ b/roles/bookstack/meta/main.yml @@ -0,0 +1,25 @@ +galaxy_info: + author: digitalboard + description: Deploy BookStack as a self-contained Docker Compose stack behind Traefik + company: digitalboard + license: MIT + + min_ansible_version: "2.14" + + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble + + galaxy_tags: + - docker + - bookstack + - wiki + - documentation + - digitalboard + +dependencies: [] diff --git a/roles/bookstack/tasks/main.yml b/roles/bookstack/tasks/main.yml new file mode 100644 index 0000000..1ea325b --- /dev/null +++ b/roles/bookstack/tasks/main.yml @@ -0,0 +1,223 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for bookstack + +# ===================================================================== +# 1. VALIDATE REQUIRED SECRETS +# ===================================================================== + +- name: Assert required secrets are set + ansible.builtin.assert: + that: + - bookstack_db_root_password | length > 0 + - bookstack_db_password | length > 0 + - bookstack_admin_password | length > 0 + - (not bookstack_oidc_enabled) or (bookstack_oidc_client_id | length > 0) + - (not bookstack_oidc_enabled) or (bookstack_oidc_client_secret | length > 0) + - (not bookstack_oidc_enabled) or (bookstack_entra_tenant_id | length > 0) + fail_msg: >- + One or more required secrets are unset. Provide them via OpenBao + lookup, Ansible Vault or --extra-vars. See README for the full list. + quiet: true + +# ===================================================================== +# 2. PREPARATION: Packages, directories, APP_KEY +# ===================================================================== + +- name: Ensure required packages are installed + ansible.builtin.package: + name: + - python3-docker + - python3-requests + state: present + +- name: Create docker compose directory + ansible.builtin.file: + path: "{{ bookstack_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create BookStack data directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ bookstack_puid }}" + group: "{{ bookstack_pgid }}" + mode: '0755' + loop: + - "{{ bookstack_docker_volume_dir }}" + - "{{ bookstack_appdata_dir }}" + - "{{ bookstack_db_data_dir }}" + - "{{ bookstack_backup_dir }}" + +- name: Verify Traefik network exists + community.docker.docker_network_info: + name: "{{ bookstack_traefik_network }}" + register: _traefik_net + failed_when: not _traefik_net.exists + +- name: Check whether APP_KEY has been generated before + ansible.builtin.stat: + path: "{{ bookstack_docker_volume_dir }}/.app_key" + register: _app_key_file + +- name: Generate persistent APP_KEY on first run + ansible.builtin.shell: | + set -o pipefail + umask 077 + echo "base64:$(openssl rand -base64 32)" > {{ bookstack_docker_volume_dir }}/.app_key + args: + executable: /bin/bash + creates: "{{ bookstack_docker_volume_dir }}/.app_key" + when: + - not _app_key_file.stat.exists + - bookstack_app_key | length == 0 + +- name: Write inventory-provided APP_KEY + ansible.builtin.copy: + content: "{{ bookstack_app_key }}\n" + dest: "{{ bookstack_docker_volume_dir }}/.app_key" + mode: '0600' + when: + - not _app_key_file.stat.exists + - bookstack_app_key | length > 0 + no_log: true + +- name: Read APP_KEY back into a fact + ansible.builtin.slurp: + src: "{{ bookstack_docker_volume_dir }}/.app_key" + register: _app_key_slurp + no_log: true + +- name: Register APP_KEY fact + ansible.builtin.set_fact: + bookstack_resolved_app_key: "{{ _app_key_slurp.content | b64decode | trim }}" + no_log: true + +# ===================================================================== +# 3. DEPLOY: Render compose, bring stack up +# ===================================================================== + +- name: Render docker-compose.yml for BookStack + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ bookstack_docker_compose_dir }}/docker-compose.yml" + mode: '0640' + notify: restart bookstack + +- name: Start BookStack containers + community.docker.docker_compose_v2: + project_src: "{{ bookstack_docker_compose_dir }}" + state: present + pull: always + wait: true + +# ===================================================================== +# 4. CONFIGURE: Wait for app and seed initial admin user +# ===================================================================== + +- name: Wait for BookStack to be ready + ansible.builtin.command: + cmd: docker exec {{ bookstack_service_name }} curl -sf -o /dev/null -w "%{http_code}" http://localhost/login + register: _bookstack_health + retries: 30 + delay: 5 + until: _bookstack_health.stdout == "200" + changed_when: false + +- name: Wait for BookStack migrations to be complete + community.docker.docker_container_exec: + container: "{{ bookstack_service_name }}-db" + argv: + - mariadb + - --protocol=tcp + - -h + - 127.0.0.1 + - -u + - "{{ bookstack_db_user }}" + - "-p{{ bookstack_db_password }}" + - "{{ bookstack_db_name }}" + - -Nse + - "SHOW TABLES LIKE 'users';" + register: _users_table + retries: 30 + delay: 5 + until: _users_table.stdout | trim == 'users' + changed_when: false + no_log: true + +- name: Check whether the initial admin already exists + community.docker.docker_container_exec: + container: "{{ bookstack_service_name }}-db" + argv: + - mariadb + - --protocol=tcp + - -h + - 127.0.0.1 + - -u + - "{{ bookstack_db_user }}" + - "-p{{ bookstack_db_password }}" + - "{{ bookstack_db_name }}" + - -Nse + - "SELECT COUNT(*) FROM users WHERE email = '{{ bookstack_admin_email }}';" + register: _admin_exists + changed_when: false + no_log: true + +- name: Create initial admin user + community.docker.docker_container_exec: + container: "{{ bookstack_service_name }}" + argv: + - php + - "{{ bookstack_artisan_path }}" + - bookstack:create-admin + - "--email={{ bookstack_admin_email }}" + - "--name={{ bookstack_admin_name }}" + - "--password={{ bookstack_admin_password }}" + when: (_admin_exists.stdout | trim | int) == 0 + no_log: true + +# ===================================================================== +# 5. BACKUP: systemd timer for daily DB + uploads dump +# ===================================================================== + +- name: Render backup script + ansible.builtin.template: + src: backup.sh.j2 + dest: /usr/local/bin/bookstack-backup.sh + owner: root + group: root + mode: '0750' + when: bookstack_backup_enabled | bool + +- name: Render backup systemd service + ansible.builtin.template: + src: bookstack-backup.service.j2 + dest: /etc/systemd/system/bookstack-backup.service + mode: '0644' + when: bookstack_backup_enabled | bool + notify: reload systemd + +- name: Render backup systemd timer + ansible.builtin.template: + src: bookstack-backup.timer.j2 + dest: /etc/systemd/system/bookstack-backup.timer + mode: '0644' + when: bookstack_backup_enabled | bool + notify: reload systemd + +- name: Enable and start backup timer + ansible.builtin.systemd: + name: bookstack-backup.timer + enabled: true + state: started + daemon_reload: true + when: bookstack_backup_enabled | bool + +- name: Disable backup timer when feature is off + ansible.builtin.systemd: + name: bookstack-backup.timer + enabled: false + state: stopped + when: not (bookstack_backup_enabled | bool) + failed_when: false diff --git a/roles/bookstack/templates/backup.sh.j2 b/roles/bookstack/templates/backup.sh.j2 new file mode 100644 index 0000000..65217c2 --- /dev/null +++ b/roles/bookstack/templates/backup.sh.j2 @@ -0,0 +1,41 @@ +#!/bin/bash +# {{ ansible_managed }} +set -euo pipefail + +BACKUP_DIR="{{ bookstack_backup_dir }}" +RETENTION_DAYS={{ bookstack_backup_retention_days }} +APPDATA_DIR="{{ bookstack_appdata_dir }}" +STAMP="$(date +%Y%m%d-%H%M%S)" + +mkdir -p "$BACKUP_DIR" + +# --- DB dump (mariadb-dump from inside the DB container) --- +# Use the app user via TCP because root@localhost is unix_socket-auth only +# in the LSIO MariaDB image and root@% does not exist. +docker exec {{ bookstack_service_name }}-db \ + mariadb-dump \ + --protocol=tcp -h 127.0.0.1 \ + -u "{{ bookstack_db_user }}" -p"{{ bookstack_db_password }}" \ + --single-transaction --routines --triggers --quick \ + "{{ bookstack_db_name }}" \ + | gzip -9 > "$BACKUP_DIR/bookstack-db-$STAMP.sql.gz" + +# --- File uploads (images, attachments) --- +# LSIO BookStack stores user uploads under /config/www/{uploads,storage/uploads,files}. +tar --warning=no-file-changed \ + -czf "$BACKUP_DIR/bookstack-files-$STAMP.tar.gz" \ + -C "$APPDATA_DIR/www" \ + uploads storage/uploads files 2>/dev/null || true + +# --- APP_KEY backup (critical for restore!) --- +install -m 0600 "{{ bookstack_docker_volume_dir }}/.app_key" \ + "$BACKUP_DIR/bookstack-appkey-$STAMP.txt" + +# --- Retention --- +find "$BACKUP_DIR" -type f \ + \( -name 'bookstack-db-*.sql.gz' \ + -o -name 'bookstack-files-*.tar.gz' \ + -o -name 'bookstack-appkey-*.txt' \) \ + -mtime +"$RETENTION_DAYS" -delete + +echo "Backup complete: $STAMP" \ No newline at end of file diff --git a/roles/bookstack/templates/bookstack-backup.service.j2 b/roles/bookstack/templates/bookstack-backup.service.j2 new file mode 100644 index 0000000..cb63795 --- /dev/null +++ b/roles/bookstack/templates/bookstack-backup.service.j2 @@ -0,0 +1,12 @@ +# {{ ansible_managed }} +[Unit] +Description=BookStack backup (DB + uploads) +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/bookstack-backup.sh +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 diff --git a/roles/bookstack/templates/bookstack-backup.timer.j2 b/roles/bookstack/templates/bookstack-backup.timer.j2 new file mode 100644 index 0000000..e13238d --- /dev/null +++ b/roles/bookstack/templates/bookstack-backup.timer.j2 @@ -0,0 +1,11 @@ +# {{ ansible_managed }} +[Unit] +Description=Daily BookStack backup + +[Timer] +OnCalendar={{ bookstack_backup_schedule }} +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target diff --git a/roles/bookstack/templates/docker-compose.yml.j2 b/roles/bookstack/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..863e316 --- /dev/null +++ b/roles/bookstack/templates/docker-compose.yml.j2 @@ -0,0 +1,87 @@ +#---------------------------------------------------------------------# +# BookStack - Self-hosted wiki / knowledge base. # +#---------------------------------------------------------------------# +--- +services: + {{ bookstack_service_name }}: + image: {{ bookstack_image }} + container_name: {{ bookstack_service_name }} + restart: unless-stopped + environment: + PUID: "{{ bookstack_puid }}" + PGID: "{{ bookstack_pgid }}" + TZ: "{{ bookstack_tz }}" + APP_URL: "{{ bookstack_base_url }}" + APP_KEY: "{{ bookstack_resolved_app_key }}" + DB_HOST: "{{ bookstack_service_name }}-db" + DB_PORT: "3306" + DB_DATABASE: "{{ bookstack_db_name }}" + DB_USERNAME: "{{ bookstack_db_user }}" + DB_PASSWORD: "{{ bookstack_db_password }}" + MAIL_DRIVER: "{{ bookstack_mail_driver }}" + MAIL_HOST: "{{ bookstack_mail_host }}" + MAIL_PORT: "{{ bookstack_mail_port }}" + MAIL_USERNAME: "{{ bookstack_mail_username }}" + MAIL_PASSWORD: "{{ bookstack_mail_password }}" + MAIL_ENCRYPTION: "{{ bookstack_mail_encryption }}" + MAIL_FROM: "{{ bookstack_mail_from }}" + MAIL_FROM_NAME: "{{ bookstack_mail_from_name }}" +{% if bookstack_oidc_enabled %} + AUTH_METHOD: "oidc" + AUTH_AUTO_INITIATE: "{{ bookstack_oidc_auto_initiate | string | lower }}" + OIDC_NAME: "{{ bookstack_oidc_name }}" + OIDC_DISPLAY_NAME_CLAIMS: "name" + OIDC_CLIENT_ID: "{{ bookstack_oidc_client_id }}" + OIDC_CLIENT_SECRET: "{{ bookstack_oidc_client_secret }}" + OIDC_ISSUER: "{{ bookstack_oidc_issuer }}" + OIDC_ISSUER_DISCOVER: "true" + OIDC_END_SESSION_ENDPOINT: "true" + OIDC_ADDITIONAL_SCOPES: "{{ bookstack_oidc_additional_scopes }}" + OIDC_USER_TO_GROUPS: "{{ bookstack_oidc_user_to_groups | string | lower }}" + OIDC_GROUPS_CLAIM: "{{ bookstack_oidc_groups_claim }}" +{% endif %} + volumes: + - {{ bookstack_appdata_dir }}:/config + networks: + - {{ bookstack_traefik_network }} + - internal + depends_on: + {{ bookstack_service_name }}-db: + condition: service_healthy + labels: + - "traefik.enable=true" + - "traefik.docker.network={{ bookstack_traefik_network }}" + - "traefik.http.routers.{{ bookstack_service_name }}.rule=Host(`{{ bookstack_domain }}`)" + - "traefik.http.routers.{{ bookstack_service_name }}.entrypoints=websecure" + - "traefik.http.routers.{{ bookstack_service_name }}.tls=true" + - "traefik.http.routers.{{ bookstack_service_name }}.tls.certresolver={{ bookstack_traefik_certresolver }}" + - "traefik.http.services.{{ bookstack_service_name }}.loadbalancer.server.port=80" + + {{ bookstack_service_name }}-db: + image: {{ bookstack_db_image }} + container_name: {{ bookstack_service_name }}-db + restart: unless-stopped + environment: + PUID: "{{ bookstack_puid }}" + PGID: "{{ bookstack_pgid }}" + TZ: "{{ bookstack_tz }}" + MYSQL_ROOT_PASSWORD: "{{ bookstack_db_root_password }}" + MYSQL_DATABASE: "{{ bookstack_db_name }}" + MYSQL_USER: "{{ bookstack_db_user }}" + MYSQL_PASSWORD: "{{ bookstack_db_password }}" + volumes: + - {{ bookstack_db_data_dir }}:/config + networks: + - internal + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -u root --password=\"$$MYSQL_ROOT_PASSWORD\" --silent"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 60s + +networks: + {{ bookstack_traefik_network }}: + external: true + internal: + driver: bridge diff --git a/roles/bookstack/tests/inventory b/roles/bookstack/tests/inventory new file mode 100644 index 0000000..2fbb50c --- /dev/null +++ b/roles/bookstack/tests/inventory @@ -0,0 +1 @@ +localhost diff --git a/roles/bookstack/tests/test.yml b/roles/bookstack/tests/test.yml new file mode 100644 index 0000000..15b9be3 --- /dev/null +++ b/roles/bookstack/tests/test.yml @@ -0,0 +1,5 @@ +--- +- hosts: localhost + remote_user: root + roles: + - bookstack diff --git a/roles/bookstack/vars/main.yml b/roles/bookstack/vars/main.yml new file mode 100644 index 0000000..e04b89a --- /dev/null +++ b/roles/bookstack/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for bookstack diff --git a/roles/homarr/filter_plugins/tests/test_homarr_layout.py b/roles/homarr/filter_plugins/tests/test_homarr_layout.py index 3a49f2b..a96d672 100644 --- a/roles/homarr/filter_plugins/tests/test_homarr_layout.py +++ b/roles/homarr/filter_plugins/tests/test_homarr_layout.py @@ -15,7 +15,11 @@ import sys # Make the filter importable without having Ansible auto-discovery in # the way (it would only run during a real `ansible-playbook` invocation). -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +sys.path.insert( + 0, + os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', + 'plugins', 'filter') +) import pytest # noqa: E402 diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index 9d00cde..ffb0bb7 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -136,7 +136,7 @@ - name: Compute Homarr app layouts ansible.builtin.set_fact: - homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}" + homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}" - name: Show computed app layouts ansible.builtin.debug: From 2c2dbbc648dea8deecf240bdb163f3aa2d532da6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:13:30 +0200 Subject: [PATCH 11/19] docs(bookstack): add meta/argument_specs.yml 47 typed options covering the full defaults file plus the OIDC and backup-timer subsystems. The three secrets the role asserts on (db_root_password, db_password, admin_password) are marked required: true so ansible refuses the play with a clear error before the validate task even runs. Loads cleanly through ansible-core's ArgumentSpecValidator with 100% defaults/spec coverage. Matches the spec convention used by traefik, authentik, drawio, garage, nextcloud, opnform, coturn, talk and send. --- roles/bookstack/meta/argument_specs.yml | 194 ++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 roles/bookstack/meta/argument_specs.yml diff --git a/roles/bookstack/meta/argument_specs.yml b/roles/bookstack/meta/argument_specs.yml new file mode 100644 index 0000000..8546cde --- /dev/null +++ b/roles/bookstack/meta/argument_specs.yml @@ -0,0 +1,194 @@ +--- +argument_specs: + main: + short_description: Deploy BookStack (LSIO image + MariaDB) via Docker Compose. + description: + - Renders a Compose stack for the linuxserver.io BookStack image + with a sibling MariaDB container behind Traefik, then bootstraps + the initial admin user via C(php artisan bookstack:create-admin) + and optionally enables OIDC SSO (Entra ID by default). + - "Persists the Laravel C(APP_KEY) on the host so the same key is + re-used across deploys (a fresh key would orphan all encrypted + database values: 2FA secrets, API tokens, OIDC client_secret)." + - Ships an optional systemd timer that backs up the database dump, + uploads tarball and APP_KEY daily with configurable retention. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + bookstack_service_name: + type: str + default: bookstack + bookstack_docker_compose_dir: + type: path + bookstack_docker_volume_dir: + type: path + bookstack_appdata_dir: + type: path + bookstack_db_data_dir: + type: path + bookstack_backup_dir: + type: path + + bookstack_domain: + type: str + default: wiki.local.test + description: Hostname used in the Traefik Host rule. + bookstack_base_url: + type: str + description: Defaults to C("https://{{ bookstack_domain }}"). + + bookstack_image: + type: str + default: "lscr.io/linuxserver/bookstack:version-v26.03.3" + bookstack_db_image: + type: str + default: "lscr.io/linuxserver/mariadb:11.4.9" + + bookstack_traefik_network: + type: str + default: proxy + bookstack_traefik_certresolver: + type: str + default: le + + bookstack_tz: + type: str + default: Europe/Zurich + bookstack_puid: + type: str + default: "1000" + bookstack_pgid: + type: str + default: "1000" + + bookstack_db_name: + type: str + default: bookstack + bookstack_db_user: + type: str + default: bookstack + bookstack_db_root_password: + type: str + required: true + description: MariaDB C(root) password. Override per-inventory. + bookstack_db_password: + type: str + required: true + description: MariaDB C(bookstack_db_user) password. Override per-inventory. + + bookstack_admin_password: + type: str + required: true + description: + - Password for the local admin user that the role creates via + C(bookstack:create-admin). Lives alongside any OIDC users. + + bookstack_app_key: + type: str + default: '' + description: + - When empty the role generates a persistent C(APP_KEY) on first + run and stores it under C({{ bookstack_docker_volume_dir }}/.app_key). + Override only when restoring an existing instance — a mismatching + key orphans all encrypted database values. + + bookstack_admin_name: + type: str + default: Admin + bookstack_admin_email: + type: str + default: admin@local.test + bookstack_artisan_path: + type: path + default: /app/www/artisan + description: + - Path to BookStack's C(artisan) script inside the container. The + LSIO image's C(WORKDIR) is not the app directory, so this must + be absolute. + + bookstack_mail_driver: + type: str + choices: [smtp, log, sendmail, mailgun, ses, postmark] + default: smtp + bookstack_mail_host: + type: str + default: smtp.local.test + bookstack_mail_port: + type: int + default: 587 + bookstack_mail_encryption: + type: str + choices: [tls, ssl, ''] + default: tls + bookstack_mail_from: + type: str + default: bookstack@local.test + bookstack_mail_from_name: + type: str + default: BookStack + bookstack_mail_username: + type: str + default: '' + bookstack_mail_password: + type: str + default: '' + + bookstack_oidc_enabled: + type: bool + default: false + bookstack_oidc_name: + type: str + default: SSO + description: Display name of the SSO button on the login page. + bookstack_entra_tenant_id: + type: str + default: '' + description: Entra tenant UUID. Required when C(bookstack_oidc_enabled=true). + bookstack_oidc_issuer: + type: str + description: + - OIDC issuer URL. Defaults to the Entra v2 issuer template + built from C(bookstack_entra_tenant_id). Override for + Keycloak or any other provider. + bookstack_oidc_client_id: + type: str + default: '' + description: Required when C(bookstack_oidc_enabled=true). + bookstack_oidc_client_secret: + type: str + default: '' + description: Required when C(bookstack_oidc_enabled=true). + bookstack_oidc_auto_initiate: + type: bool + default: false + description: + - When true users are redirected straight to the IdP and the + local login is reachable only via C(?email_login=1). + bookstack_oidc_user_to_groups: + type: bool + default: false + description: + - When true BookStack syncs roles from the IdP groups claim + on every login. Requires BookStack roles whose + C(External Auth ID) matches the IdP group's Object ID. + bookstack_oidc_groups_claim: + type: str + default: groups + bookstack_oidc_additional_scopes: + type: str + default: openid profile email + + bookstack_backup_enabled: + type: bool + default: true + bookstack_backup_retention_days: + type: int + default: 14 + bookstack_backup_schedule: + type: str + default: "*-*-* 03:00:00" + description: systemd C(OnCalendar) expression for the backup timer. From 05fb62c75d597d15f60dc0be6ebec9efb7f3f4f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=BCst?= Date: Fri, 22 May 2026 01:10:56 +0200 Subject: [PATCH 12/19] feat(talk/turn/signaling/hpb): add role for Talk with backend services --- roles/coturn/README.md | 69 ++++++++++ roles/coturn/defaults/main.yml | 77 +++++++++++ roles/coturn/handlers/main.yml | 10 ++ roles/coturn/meta/main.yml | 15 +++ roles/coturn/tasks/main.yml | 110 ++++++++++++++++ roles/coturn/templates/docker-compose.yml.j2 | 78 +++++++++++ roles/coturn/tests/inventory | 2 + roles/coturn/tests/tests.yml | 6 + roles/coturn/vars/main.yml | 3 + roles/nextcloud/defaults/main.yml | 20 +++ roles/nextcloud/tasks/main.yml | 4 + roles/nextcloud/tasks/talk.yml | 70 ++++++++++ roles/talk/README.md | 78 +++++++++++ roles/talk/defaults/main.yml | 74 +++++++++++ roles/talk/handlers/main.yml | 8 ++ roles/talk/meta/main.yml | 15 +++ roles/talk/tasks/main.yml | 85 ++++++++++++ roles/talk/templates/docker-compose.yml.j2 | 124 ++++++++++++++++++ roles/talk/templates/janus.jcfg.j2 | 28 ++++ roles/talk/templates/janus.logger.jcfg.j2 | 3 + .../janus.transport.websockets.jcfg.j2 | 7 + roles/talk/templates/server.conf.j2 | 33 +++++ roles/talk/tests/inventory | 2 + roles/talk/tests/test.yml | 6 + roles/talk/vars/main.yml | 3 + 25 files changed, 930 insertions(+) create mode 100644 roles/coturn/README.md create mode 100644 roles/coturn/defaults/main.yml create mode 100644 roles/coturn/handlers/main.yml create mode 100644 roles/coturn/meta/main.yml create mode 100644 roles/coturn/tasks/main.yml create mode 100644 roles/coturn/templates/docker-compose.yml.j2 create mode 100644 roles/coturn/tests/inventory create mode 100644 roles/coturn/tests/tests.yml create mode 100644 roles/coturn/vars/main.yml create mode 100644 roles/nextcloud/tasks/talk.yml create mode 100644 roles/talk/README.md create mode 100644 roles/talk/defaults/main.yml create mode 100644 roles/talk/handlers/main.yml create mode 100644 roles/talk/meta/main.yml create mode 100644 roles/talk/tasks/main.yml create mode 100644 roles/talk/templates/docker-compose.yml.j2 create mode 100644 roles/talk/templates/janus.jcfg.j2 create mode 100644 roles/talk/templates/janus.logger.jcfg.j2 create mode 100644 roles/talk/templates/janus.transport.websockets.jcfg.j2 create mode 100644 roles/talk/templates/server.conf.j2 create mode 100644 roles/talk/tests/inventory create mode 100644 roles/talk/tests/test.yml create mode 100644 roles/talk/vars/main.yml diff --git a/roles/coturn/README.md b/roles/coturn/README.md new file mode 100644 index 0000000..13d1c3e --- /dev/null +++ b/roles/coturn/README.md @@ -0,0 +1,69 @@ +# coturn + +Deploys a [coturn](https://github.com/coturn/coturn) TURN/STUN server with `network_mode: host`, +optionally accompanied by an `acme.sh` sidecar that obtains and renews a public TLS certificate +via RFC2136 (`nsupdate`) and restarts coturn on renewal. + +This is the recommended pairing for `digitalboard.core.talk` (Nextcloud Talk HPB). + +## What it does + +- Renders `/etc/docker/compose/coturn/docker-compose.yml` +- (acme mode) Deploys the TSIG key from `playbooks/secrets/{{ inventory_hostname }}/nsupdate.key` +- (selfsigned mode) Generates an ECC keypair + selfsigned cert in `{{ coturn_cert_dir }}` +- Starts the stack via `community.docker.docker_compose_v2` + +## Required variables + +| Variable | Description | +|---|---| +| `coturn_realm` | Public DNS name used as realm + cert CN (e.g. `stun.digitalboard.ch`) | +| `coturn_external_ip` | Mapping for `--external-ip`, format `PUBLIC[/PRIVATE]` | +| `coturn_static_auth_secret` | Shared secret for HMAC-based credentials; **must match** `talk_turn_secret` on the HPB host | + +## Important variables + +| Variable | Default | Description | +|---|---|---| +| `coturn_cert_mode` | `file` | One of `acme`, `file`, `selfsigned` | +| `coturn_listening_port` | `443` | TCP/UDP non-TLS port | +| `coturn_tls_listening_port` | `443` | TLS port (shared with non-TLS via STUN mux) | +| `coturn_min_relay_port` / `coturn_max_relay_port` | `49160` / `49200` | UDP relay range | +| `coturn_internal_realm` | `""` | Optional second SAN for split-horizon DNS | +| `coturn_image` | `coturn/coturn:4.6.2-r5-alpine` | Pinned by default; override as needed | + +## ACME / nsupdate mode + +When `coturn_cert_mode: acme` is set, also configure: + +```yaml +coturn_acme_email: "admin@digitalboard.ch" +coturn_acme_nsupdate_server: "ns1.digitalboard.ch" +coturn_acme_nsupdate_server_ip: "172.16.9.169" # optional pin +coturn_acme_nsupdate_zone: "digitalboard._acme.digitalboard.ch" +# optional: override the auto-built challenge alias mapping +coturn_acme_challenge_aliases: + - name: stun.digitalboard.ch + alias: stun.digitalboard._acme.digitalboard.ch + - name: stun.int.digitalboard.ch + alias: stun.int.digitalboard._acme.digitalboard.ch +``` + +Place your TSIG key at `playbooks/secrets/{{ inventory_hostname }}/nsupdate.key` (mode 0600). + +## Secrets + +Place the static auth secret at: + +``` +playbooks/secrets/{{ inventory_hostname }}/coturn_static_auth_secret +``` + +Mode 0600. The same value must be deployed to the HPB host as `talk_turn_secret`. + +## Firewall + +The role does not manage firewall rules. Ensure the host has: + +- `443/tcp` and `443/udp` reachable from the internet +- UDP `{{ coturn_min_relay_port }}-{{ coturn_max_relay_port }}` reachable from the internet diff --git a/roles/coturn/defaults/main.yml b/roles/coturn/defaults/main.yml new file mode 100644 index 0000000..580d9da --- /dev/null +++ b/roles/coturn/defaults/main.yml @@ -0,0 +1,77 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for coturn + +# Base directories (inherited from base role) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# Service-specific paths +coturn_service_name: coturn +coturn_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ coturn_service_name }}" +coturn_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ coturn_service_name }}" + +# Container images (pin per host_vars in production) +coturn_image: "coturn/coturn:4.6.2-r5-alpine" +coturn_acme_image: "neilpang/acme.sh:3.1.0" + +# Public DNS name used for the realm and the public certificate +coturn_realm: "stun.example.test" +# Optional second DNS name issued on the same certificate (for split-horizon "internal" name) +coturn_internal_realm: "" # e.g. "stun.int.example.test" + +# Ports +# Defaults follow IANA standards (3478/TURN, 5349/TURNS) so coturn can +# co-exist with a Traefik instance on the same host. Override to 443/443 +# in restrictive-network environments where punching through firewalls matters. +coturn_listening_port: 3478 # TURN / STUN (TCP+UDP) +coturn_tls_listening_port: 5349 # TURNS (TCP+UDP) +coturn_min_relay_port: 49160 +coturn_max_relay_port: 49200 + +# IP advertisement: must be set in host_vars for production +# Format follows coturn's --external-ip: "PUBLIC_IP" or "PUBLIC_IP/PRIVATE_IP" +coturn_external_ip: "" # e.g. "203.0.113.10/172.18.0.2" +coturn_listening_ip: "0.0.0.0" + +# Shared secret used by HPB to mint short-lived TURN credentials. +# Loaded by default from a plain file in playbooks/secrets/{host}/coturn_static_auth_secret +# Override per host_vars if you want to use a vault or different lookup. +coturn_static_auth_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/coturn_static_auth_secret') }}" + +# Additional CLI flags (list of strings, appended verbatim to command:) +coturn_extra_args: [] + +# --- TLS certificate --- +# 'acme' : run an acme.sh sidecar that issues + renews via RFC2136 / nsupdate, restarts coturn +# 'file' : assume a certificate already lives at {{ coturn_cert_dir }}/{{ coturn_cert_file }} on the host (you manage it) +# 'selfsigned' : generate a selfsigned cert on first run (for vagrant/dev only) +coturn_cert_mode: "file" + +coturn_cert_dir: "{{ docker_volume_base_dir }}/acme/certs" +coturn_cert_file: "fullchain.cer" +coturn_key_file: "{{ coturn_realm }}.key" + +# --- acme.sh sidecar (only used when coturn_cert_mode == 'acme') --- +coturn_acme_email: "admin@example.test" +coturn_acme_directory: "https://acme-v02.api.letsencrypt.org/directory" +# Stage URL for testing: "https://acme-staging-v02.api.letsencrypt.org/directory" +coturn_acme_keylength: "ec-256" +coturn_acme_dnssleep: 60 +coturn_acme_data_dir: "{{ docker_volume_base_dir }}/acme/acme" + +# DNS-01 RFC2136 / nsupdate configuration +coturn_acme_nsupdate_server: "" # e.g. "ns1.example.test" +coturn_acme_nsupdate_server_ip: "" # optional extra_hosts pin (string IP) for the server +coturn_acme_nsupdate_zone: "" # e.g. "example._acme.example.test" +# Per-name challenge alias zones (one entry per SAN) +# When empty (default), built automatically as "{{ realm }}._acme.{{ zone-tail }}" +coturn_acme_challenge_aliases: [] +# Example: +# - name: stun.example.test +# alias: stun.example._acme.example.test +# - name: stun.int.example.test +# alias: stun.int.example._acme.example.test + +# Path of the TSIG key file inside the container (mounted from secrets) +coturn_acme_nsupdate_key_src: "{{ playbook_dir }}/secrets/{{ inventory_hostname }}/nsupdate.key" diff --git a/roles/coturn/handlers/main.yml b/roles/coturn/handlers/main.yml new file mode 100644 index 0000000..0abd12f --- /dev/null +++ b/roles/coturn/handlers/main.yml @@ -0,0 +1,10 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for coturn + +- name: Restart coturn container + community.docker.docker_compose_v2: + project_src: "{{ coturn_docker_compose_dir }}" + state: restarted + services: + - coturn diff --git a/roles/coturn/meta/main.yml b/roles/coturn/meta/main.yml new file mode 100644 index 0000000..68d93a9 --- /dev/null +++ b/roles/coturn/meta/main.yml @@ -0,0 +1,15 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: Digital Board Team + description: Deploy a coturn TURN/STUN server with optional acme.sh sidecar (RFC2136/nsupdate) + company: digitalboard.ch + license: GPL-2.0-or-later + min_ansible_version: "2.14" + galaxy_tags: + - turn + - stun + - coturn + - webrtc + - nextcloud + - talk +dependencies: [] diff --git a/roles/coturn/tasks/main.yml b/roles/coturn/tasks/main.yml new file mode 100644 index 0000000..cf9c15a --- /dev/null +++ b/roles/coturn/tasks/main.yml @@ -0,0 +1,110 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for coturn + +- name: Assert minimum configuration + ansible.builtin.assert: + that: + - coturn_realm | length > 0 + - coturn_external_ip | length > 0 + - coturn_static_auth_secret | length > 0 + fail_msg: > + coturn_realm, coturn_external_ip and coturn_static_auth_secret must be set. + Provide them in host_vars or via a secrets file. + +- name: Create coturn compose directory + ansible.builtin.file: + path: "{{ coturn_docker_compose_dir }}" + state: directory + mode: "0755" + +- name: Create coturn data directory + ansible.builtin.file: + path: "{{ coturn_docker_volume_dir }}" + state: directory + mode: "0755" + +- name: Create certificate directory + ansible.builtin.file: + path: "{{ coturn_cert_dir }}" + state: directory + mode: "0755" + +# --- TLS certificate provisioning ------------------------------------------------- + +- name: Configure acme.sh sidecar (TSIG key + acme data dir) + when: coturn_cert_mode == 'acme' + block: + - name: Create acme.sh data directory + ansible.builtin.file: + path: "{{ coturn_acme_data_dir }}" + state: directory + mode: "0700" + + - name: Deploy nsupdate TSIG key + ansible.builtin.copy: + src: "{{ coturn_acme_nsupdate_key_src }}" + dest: "{{ coturn_docker_compose_dir }}/nsupdate.key" + mode: "0600" + no_log: true + notify: Restart coturn container + + - name: Build effective challenge alias list (default if not provided) + ansible.builtin.set_fact: + _coturn_challenge_aliases: >- + {{ coturn_acme_challenge_aliases + if coturn_acme_challenge_aliases | length > 0 + else ( + [{'name': coturn_realm, + 'alias': (coturn_realm.split('.')[:-2] | join('.')) ~ '.' ~ coturn_acme_nsupdate_zone }] + + ([{'name': coturn_internal_realm, + 'alias': (coturn_internal_realm.split('.')[:-2] | join('.')) ~ '.' ~ coturn_acme_nsupdate_zone }] + if coturn_internal_realm | length > 0 else []) + ) + }} + +- name: Generate selfsigned certificate (vagrant / dev only) + when: coturn_cert_mode == 'selfsigned' + block: + - name: Ensure openssl is available + ansible.builtin.package: + name: openssl + state: present + + - name: Generate selfsigned private key + community.crypto.openssl_privatekey: + path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}" + type: ECC + curve: secp256r1 + mode: "0600" + + - name: Generate selfsigned CSR + community.crypto.openssl_csr: + path: "{{ coturn_cert_dir }}/{{ coturn_realm }}.csr" + privatekey_path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}" + common_name: "{{ coturn_realm }}" + subject_alt_name: + - "DNS:{{ coturn_realm }}" + mode: "0644" + + - name: Issue selfsigned certificate + community.crypto.x509_certificate: + path: "{{ coturn_cert_dir }}/{{ coturn_cert_file }}" + privatekey_path: "{{ coturn_cert_dir }}/{{ coturn_key_file }}" + csr_path: "{{ coturn_cert_dir }}/{{ coturn_realm }}.csr" + provider: selfsigned + mode: "0644" + +# --- Compose + start -------------------------------------------------------------- + +- name: Generate docker-compose.yml for coturn + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ coturn_docker_compose_dir }}/docker-compose.yml" + mode: "0644" + notify: Restart coturn container + +- name: Start coturn stack + community.docker.docker_compose_v2: + project_src: "{{ coturn_docker_compose_dir }}" + state: present diff --git a/roles/coturn/templates/docker-compose.yml.j2 b/roles/coturn/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..42bdcb5 --- /dev/null +++ b/roles/coturn/templates/docker-compose.yml.j2 @@ -0,0 +1,78 @@ +services: + coturn: + image: {{ coturn_image }} + container_name: {{ coturn_service_name }} + restart: always + network_mode: host + volumes: + - {{ coturn_cert_dir }}:/certs:ro + command: + - --use-auth-secret + - --static-auth-secret={{ coturn_static_auth_secret }} + - --realm={{ coturn_realm }} + - --fingerprint + - --no-multicast-peers + - --no-cli + - --listening-ip={{ coturn_listening_ip }} + - --listening-port={{ coturn_listening_port }} + - --tls-listening-port={{ coturn_tls_listening_port }} + - --min-port={{ coturn_min_relay_port }} + - --max-port={{ coturn_max_relay_port }} + - --cert=/certs/{{ coturn_cert_file }} + - --pkey=/certs/{{ coturn_key_file }} + - --external-ip={{ coturn_external_ip }} +{% for arg in coturn_extra_args %} + - {{ arg }} +{% endfor %} + +{% if coturn_cert_mode == 'acme' %} + acme: + image: {{ coturn_acme_image }} + container_name: acme-{{ coturn_service_name }} + restart: always + environment: + NSUPDATE_SERVER: "{{ coturn_acme_nsupdate_server }}" + NSUPDATE_KEY: "/acme.sh/nsupdate.key" + ACME_DIRECTORY: "{{ coturn_acme_directory }}" + NSUPDATE_ZONE: "{{ coturn_acme_nsupdate_zone }}" +{% if coturn_acme_nsupdate_server_ip | length > 0 %} + extra_hosts: + - "{{ coturn_acme_nsupdate_server }}:{{ coturn_acme_nsupdate_server_ip }}" +{% endif %} + volumes: + - {{ coturn_cert_dir }}:/certs + - /var/run/docker.sock:/var/run/docker.sock + - {{ coturn_docker_compose_dir }}/nsupdate.key:/acme.sh/nsupdate.key:ro + - {{ coturn_acme_data_dir }}:/acme.sh + entrypoint: + - /bin/sh + - -c + - | + set -eu + acme.sh --set-default-ca --server "$$ACME_DIRECTORY" + acme.sh --register-account -m {{ coturn_acme_email }} || true + set +e + acme.sh --issue \ +{% for san in _coturn_challenge_aliases %} + -d {{ san.name }} \ + --challenge-alias {{ san.alias }} \ +{% endfor %} + --dns dns_nsupdate \ + --keylength {{ coturn_acme_keylength }} \ + --dnssleep {{ coturn_acme_dnssleep }} + rc=$$? + set -e + if [ "$$rc" -eq 0 ]; then + echo "Issue: success" + elif [ "$$rc" -eq 2 ]; then + echo "Issue: not due, continuing" + else + echo "Issue: failed with rc=$$rc" + exit "$$rc" + fi + acme.sh --install-cert -d {{ coturn_realm }} --ecc \ + --fullchain-file /certs/{{ coturn_cert_file }} \ + --key-file /certs/{{ coturn_key_file }} \ + --reloadcmd 'curl --fail --silent --show-error --unix-socket /var/run/docker.sock -X POST http://localhost/v1.41/containers/{{ coturn_service_name }}/restart' || true + exec crond -f +{% endif %} diff --git a/roles/coturn/tests/inventory b/roles/coturn/tests/inventory new file mode 100644 index 0000000..eec845d --- /dev/null +++ b/roles/coturn/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost \ No newline at end of file diff --git a/roles/coturn/tests/tests.yml b/roles/coturn/tests/tests.yml new file mode 100644 index 0000000..828e0fb --- /dev/null +++ b/roles/coturn/tests/tests.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - coturn diff --git a/roles/coturn/vars/main.yml b/roles/coturn/vars/main.yml new file mode 100644 index 0000000..fedd529 --- /dev/null +++ b/roles/coturn/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for httpbin diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index 7535b5a..0c96046 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -61,6 +61,26 @@ nextcloud_trusted_proxies: "172.16.0.0/12" nextcloud_enable_notify_push: false nextcloud_notify_push_image: "icewind1991/notify_push:1.3.1" +# Nextcloud Talk: register external HPB signaling + TURN + STUN +# Set to true to run tasks/talk.yml after Nextcloud is up. +nextcloud_enable_talk: false + +# HPB signaling servers to register. +# Each item: { server: "https://signaling.example.test", secret: "", verify: true } +nextcloud_talk_signaling_servers: [] +# Hostnames/URLs to remove via `talk:signaling:delete` before adding the new set. +nextcloud_talk_signaling_servers_removed: [] + +# TURN servers to register. +# Each item: { server: "stun.example.test:443", secret: "", schemes: "turn,turns", protocols: "udp,tcp" } +nextcloud_talk_turn_servers: [] +# Clear the spreed.turn_servers config key before re-adding (single source of truth) +nextcloud_talk_turn_reset_before_add: true + +# Plain STUN endpoints (host:port). Optional; usually not needed if TURN servers handle STUN too. +nextcloud_talk_stun_servers: [] +nextcloud_talk_stun_servers_removed: [] + # Non-default apps to install and enable nextcloud_apps_to_install: - groupfolders diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index 8d2a5cd..e33088b 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -91,3 +91,7 @@ - name: Configure OIDC providers ansible.builtin.include_tasks: oidc.yml when: nextcloud_oidc_providers | length > 0 or nextcloud_oidc_providers_removed | length > 0 + +- name: Configure Nextcloud Talk (HPB + TURN + STUN) + ansible.builtin.include_tasks: talk.yml + when: nextcloud_enable_talk diff --git a/roles/nextcloud/tasks/talk.yml b/roles/nextcloud/tasks/talk.yml new file mode 100644 index 0000000..aaf67e3 --- /dev/null +++ b/roles/nextcloud/tasks/talk.yml @@ -0,0 +1,70 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for configuring Nextcloud Talk HPB + TURN + STUN registration + +# --- HPB / signaling ----------------------------------------------------------- + +- name: Remove HPB signaling servers no longer in use + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ talk:signaling:delete {{ item }} + loop: "{{ nextcloud_talk_signaling_servers_removed }}" + register: _talk_sig_removed + changed_when: "'deleted' in (_talk_sig_removed.stdout | default(''))" + failed_when: + - _talk_sig_removed.rc != 0 + - "'is not configured' not in (_talk_sig_removed.stderr | default(''))" + +- name: Register HPB signaling servers + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: > + php /var/www/html/occ talk:signaling:add + {{ item.server }} + {{ item.secret }} + {% if item.verify | default(true) %}--verify{% endif %} + loop: "{{ nextcloud_talk_signaling_servers }}" + no_log: true + +# --- TURN ---------------------------------------------------------------------- +# `talk:turn:add` appends without deduplication, so on each run we first clear +# the list via the underlying app config key (turn_servers, JSON array) and +# then re-add the declared set. This keeps the host_vars list as the single +# source of truth. + +- name: Reset TURN server list before re-applying + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ config:app:set spreed turn_servers --value='[]' + when: nextcloud_talk_turn_reset_before_add | bool + +- name: Register TURN servers + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: > + php /var/www/html/occ talk:turn:add + {{ item.schemes | default('turn,turns') }} + {{ item.server }} + {{ item.protocols | default('udp,tcp') }} + --secret={{ item.secret }} + loop: "{{ nextcloud_talk_turn_servers }}" + no_log: true + +# --- STUN ---------------------------------------------------------------------- + +- name: Remove STUN servers no longer in use + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ talk:stun:delete {{ item }} + loop: "{{ nextcloud_talk_stun_servers_removed }}" + register: _talk_stun_removed + changed_when: "'deleted' in (_talk_stun_removed.stdout | default(''))" + failed_when: + - _talk_stun_removed.rc != 0 + - "'is not configured' not in (_talk_stun_removed.stderr | default(''))" + +- name: Register STUN servers + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ talk:stun:add {{ item }} + loop: "{{ nextcloud_talk_stun_servers }}" diff --git a/roles/talk/README.md b/roles/talk/README.md new file mode 100644 index 0000000..28652be --- /dev/null +++ b/roles/talk/README.md @@ -0,0 +1,78 @@ +# talk + +Deploys the Nextcloud Talk High Performance Backend (HPB) stack: + +- `nextcloud-spreed-signaling` (Strukturag) +- `janus-gateway` (canyan build, WebRTC MCU) +- `nats` (internal message broker) + +Designed to be paired with the `digitalboard.core.coturn` role (TURN/STUN) and registered in +Nextcloud via the new `digitalboard.core.nextcloud` `talk.yml` task. + +## Required variables + +| Variable | Description | +|---|---| +| `talk_domain` | Public host name (e.g. `signaling.digitalboard.ch`) | +| `talk_nextcloud_url` | Base URL of the Nextcloud instance the HPB talks back to | +| `talk_janus_public_ip` | Public IP used by Janus for ICE candidate gathering (nat_1_1_mapping) | +| `talk_backend_secret` | HMAC secret shared with Nextcloud Talk; loaded from `secrets/{host}/talk_backend_secret` | +| `talk_turn_secret` | Shared secret with coturn; loaded from `secrets/{host}/talk_turn_secret` (must equal `coturn_static_auth_secret`) | +| `talk_session_hashkey` | 32-byte hex; loaded from `secrets/{host}/talk_session_hashkey` | +| `talk_session_blockkey` | 32-byte hex; loaded from `secrets/{host}/talk_session_blockkey` | + +## Important variables + +| Variable | Default | Description | +|---|---|---| +| `talk_internal_domain` | `""` | Optional split-horizon FQDN (matches the second SAN on the coturn cert) | +| `talk_turn_servers` | `turns:.../443?transport=tcp,turn:.../443` | Comma-separated TURN URI list passed to the signaling server | +| `talk_turn_realm` | `stun.example.test` | Realm advertised to clients | +| `talk_janus_stun_server` | `stun.int.example.test` | STUN endpoint Janus uses for its own ICE; default points at the internal coturn name | +| `talk_janus_rtp_port_min/max` | `20000`/`21000` | UDP/TCP relay range opened on the Janus container | +| `talk_nextcloud_extra_host_ip` | `""` | Optional pin: bind the Nextcloud FQDN to a specific backend IP (bypasses hairpin/SNI) | +| `talk_signaling_image` | `strukturag/nextcloud-spreed-signaling:1.3.4` | Pinned | +| `talk_janus_image` | `canyan/janus-gateway:1.2.4` | Pinned | +| `talk_nats_image` | `nats:2.10-alpine` | Pinned | + +All defaults can be overridden per host_vars. The configurable image variables exist explicitly because +this stack is still under active development upstream and you may want to roll forward independently. + +## Secrets + +The role expects these files under `playbooks/secrets/{{ inventory_hostname }}/`, mode 0600: + +``` +talk_backend_secret # shared with Nextcloud Talk app (HPB shared secret) +talk_turn_secret # = coturn_static_auth_secret on the TURN host +talk_session_hashkey # 32-byte hex (openssl rand -hex 32) +talk_session_blockkey # 32-byte hex (openssl rand -hex 32) +``` + +If you prefer a different secret store, override the variables directly in host_vars. + +## What gets registered in Nextcloud + +The matching `digitalboard.core.nextcloud` task `talk.yml` runs: + +- `php occ talk:signaling:add ` — register HPB +- `php occ talk:turn:add` for each entry in `nextcloud_talk_turn_servers` — register TURN + +That part lives in the **nextcloud** role and runs when `nextcloud_enable_talk: true`. + +## Traefik + +The role assumes a `digitalboard.core.traefik` instance in `backend` mode runs on the same host +(picks up Docker container labels). The public `talk_domain` then needs to be exposed via the +**DMZ Traefik**, by adding an entry to `traefik_dmz_exposed_services` in the signaling host's +`host_vars`: + +```yaml +traefik_dmz_exposed_services: + - name: signaling + domain: signaling.digitalboard.ch + port: 443 + protocol: https +``` + +(The DMZ proxy aggregates exposed services from all `backend_servers` host_vars.) diff --git a/roles/talk/defaults/main.yml b/roles/talk/defaults/main.yml new file mode 100644 index 0000000..79a3a00 --- /dev/null +++ b/roles/talk/defaults/main.yml @@ -0,0 +1,74 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for talk (Nextcloud Talk High Performance Backend) + +# Base directories (inherited from base role) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +talk_service_name: signaling +talk_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ talk_service_name }}" +talk_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ talk_service_name }}" + +# --- Container images (pinned) --- +talk_signaling_image: "strukturag/nextcloud-spreed-signaling:1.3.4" +talk_janus_image: "canyan/janus-gateway:1.2.4" +talk_nats_image: "nats:2.10-alpine" + +# --- Networking --- +talk_traefik_network: "proxy" +talk_internal_network: "hpb_internal" + +# --- Public exposure --- +talk_use_ssl: true +talk_cert_resolver: "dns" +talk_domain: "signaling.example.test" # public domain (over DMZ Traefik) +talk_internal_domain: "" # optional split-horizon "int" domain (e.g. signaling.int.example.test) + +# --- Backend (Nextcloud) registration --- +# Nextcloud base URL the HPB talks back to. Must be reachable from the HPB container. +talk_nextcloud_url: "https://cloud.example.test" +# Pin Nextcloud domain to a backend IP via extra_hosts to bypass DMZ hairpin/SNI issues +talk_nextcloud_extra_host_ip: "" # e.g. "172.16.9.88" — empty disables the pin + +# Backend HMAC secret shared with Nextcloud Talk. +# Pattern follows playbooks/secrets/{host}/; override the lookup with vault if desired. +talk_backend_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_backend_secret') }}" + +# --- TURN integration --- +# Shared secret with coturn (--static-auth-secret). Must match coturn_static_auth_secret on the TURN host. +talk_turn_secret: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_turn_secret') }}" +# TURN server URI list as understood by the signaling server. +# Defaults follow IANA standards (3478/5349). Override to ":443" in restrictive +# network environments where coturn binds on 443. +talk_turn_servers: "turns:stun.example.test:5349?transport=tcp,turn:stun.example.test:3478" +talk_turn_realm: "stun.example.test" +talk_turn_apikey: "" # optional; if empty a random one is generated on first run + +# --- Session keys (server.conf [sessions]) --- +# 32-byte hex strings. Loaded from secrets dir like the other shared secrets. +talk_session_hashkey: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_session_hashkey') }}" +talk_session_blockkey: "{{ lookup('file', playbook_dir ~ '/secrets/' ~ inventory_hostname ~ '/talk_session_blockkey') }}" + +# --- MCU (Janus) --- +talk_mcu_type: "janus" +talk_janus_public_ip: "" # set in host_vars; goes into janus nat_1_1_mapping +talk_janus_rtp_port_min: 20000 +talk_janus_rtp_port_max: 21000 +# STUN server Janus uses for its own ICE candidate gathering. Default points to internal coturn DNS name. +talk_janus_stun_server: "stun.int.example.test" +talk_janus_stun_port: 5349 +talk_janus_ice_lite: true +talk_janus_ice_tcp: true + +# --- Trusted proxies / allowed hosts for the signaling [app] section --- +talk_trusted_proxies: + - "172.16.0.0/12" + - "192.168.0.0/16" + - "10.0.0.0/8" +talk_allowed_hosts: + - "172.16.0.0/12" + +# --- Extra hosts forwarded to all three containers --- +# Pre-populated with the Nextcloud pin if talk_nextcloud_extra_host_ip is set; you can append more here. +talk_extra_hosts: [] diff --git a/roles/talk/handlers/main.yml b/roles/talk/handlers/main.yml new file mode 100644 index 0000000..645244d --- /dev/null +++ b/roles/talk/handlers/main.yml @@ -0,0 +1,8 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for talk + +- name: Restart signaling stack + community.docker.docker_compose_v2: + project_src: "{{ talk_docker_compose_dir }}" + state: restarted diff --git a/roles/talk/meta/main.yml b/roles/talk/meta/main.yml new file mode 100644 index 0000000..7857f43 --- /dev/null +++ b/roles/talk/meta/main.yml @@ -0,0 +1,15 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: Digital Board Team + description: Deploy Nextcloud Talk High Performance Backend (nextcloud-spreed-signaling + Janus + NATS) + company: digitalboard.ch + license: GPL-2.0-or-later + min_ansible_version: "2.14" + galaxy_tags: + - nextcloud + - talk + - signaling + - hpb + - janus + - webrtc +dependencies: [] diff --git a/roles/talk/tasks/main.yml b/roles/talk/tasks/main.yml new file mode 100644 index 0000000..3a984cf --- /dev/null +++ b/roles/talk/tasks/main.yml @@ -0,0 +1,85 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for talk (HPB) + +- name: Assert minimum configuration + ansible.builtin.assert: + that: + - talk_domain | length > 0 + - talk_nextcloud_url | length > 0 + - talk_backend_secret | length > 0 + - talk_turn_secret | length > 0 + - talk_janus_public_ip | length > 0 + - talk_session_hashkey | length > 0 + - talk_session_blockkey | length > 0 + fail_msg: > + Required talk_* variables missing. + Set talk_domain, talk_nextcloud_url, talk_janus_public_ip in host_vars + and place backend/turn/session secrets in playbooks/secrets/{{ inventory_hostname }}/. + +- name: Create talk compose directory + ansible.builtin.file: + path: "{{ talk_docker_compose_dir }}" + state: directory + mode: "0755" + +- name: Create signaling subdirectories (signaling + janus configs) + ansible.builtin.file: + path: "{{ talk_docker_compose_dir }}/{{ item }}" + state: directory + mode: "0755" + loop: + - signaling + - janus + +- name: Create signaling data directory + ansible.builtin.file: + path: "{{ talk_docker_volume_dir }}/signaling/data" + state: directory + mode: "0755" + +- name: Ensure proxy network exists (created externally by Traefik role normally) + community.docker.docker_network: + name: "{{ talk_traefik_network }}" + state: present + +- name: Render signaling server.conf + ansible.builtin.template: + src: server.conf.j2 + dest: "{{ talk_docker_compose_dir }}/signaling/server.conf" + mode: "0640" + no_log: true + notify: Restart signaling stack + +- name: Render Janus main config + ansible.builtin.template: + src: janus.jcfg.j2 + dest: "{{ talk_docker_compose_dir }}/janus/janus.jcfg" + mode: "0644" + notify: Restart signaling stack + +- name: Render Janus websockets transport config + ansible.builtin.template: + src: janus.transport.websockets.jcfg.j2 + dest: "{{ talk_docker_compose_dir }}/janus/janus.transport.websockets.jcfg" + mode: "0644" + notify: Restart signaling stack + +- name: Render Janus logger config + ansible.builtin.template: + src: janus.logger.jcfg.j2 + dest: "{{ talk_docker_compose_dir }}/janus/janus.logger.jcfg" + mode: "0644" + notify: Restart signaling stack + +- name: Render docker-compose.yml + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ talk_docker_compose_dir }}/docker-compose.yml" + mode: "0644" + notify: Restart signaling stack + +- name: Start signaling stack + community.docker.docker_compose_v2: + project_src: "{{ talk_docker_compose_dir }}" + state: present diff --git a/roles/talk/templates/docker-compose.yml.j2 b/roles/talk/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..f207186 --- /dev/null +++ b/roles/talk/templates/docker-compose.yml.j2 @@ -0,0 +1,124 @@ +{# Build the effective extra_hosts list once #} +{% set _extra_hosts = [] %} +{% if talk_nextcloud_extra_host_ip | length > 0 %} +{% set _ = _extra_hosts.append((talk_nextcloud_url | urlsplit('hostname')) ~ ':' ~ talk_nextcloud_extra_host_ip) %} +{% endif %} +{% for h in talk_extra_hosts %} +{% set _ = _extra_hosts.append(h) %} +{% endfor %} +networks: + {{ talk_traefik_network }}: + external: true + {{ talk_internal_network }}: + driver: bridge + +services: + nats: + image: {{ talk_nats_image }} + container_name: nats + restart: unless-stopped +{% if _extra_hosts | length > 0 %} + extra_hosts: +{% for h in _extra_hosts %} + - "{{ h }}" +{% endfor %} +{% endif %} + command: > + -js + -m 8222 + -p 4222 + healthcheck: + test: ["CMD", "nc", "-z", "localhost", "4222"] + interval: 10s + timeout: 3s + retries: 10 + networks: + - {{ talk_internal_network }} + + janus: + image: {{ talk_janus_image }} + container_name: janus + restart: unless-stopped +{% if _extra_hosts | length > 0 %} + extra_hosts: +{% for h in _extra_hosts %} + - "{{ h }}" +{% endfor %} +{% endif %} + environment: + PUBLIC_IP: "{{ talk_janus_public_ip }}" + RTP_RANGE: "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}" + volumes: + - ./janus/janus.jcfg:/usr/local/etc/janus/janus.jcfg:ro + - ./janus/janus.transport.websockets.jcfg:/usr/local/etc/janus/janus.transport.websockets.jcfg:ro + - ./janus/janus.logger.jcfg:/usr/local/etc/janus/janus.logger.jcfg:ro + networks: + - {{ talk_internal_network }} + ports: + - "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}:{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}/udp" + - "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}:{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}/tcp" + ulimits: + nofile: + soft: 65536 + hard: 65536 + + signaling: + image: {{ talk_signaling_image }} + container_name: signaling + restart: unless-stopped + depends_on: + nats: + condition: service_healthy +{% if _extra_hosts | length > 0 %} + extra_hosts: +{% for h in _extra_hosts %} + - "{{ h }}" +{% endfor %} +{% endif %} + volumes: + - ./signaling/server.conf:/config/server.conf:ro + - {{ talk_docker_volume_dir }}/signaling/data:/var/lib/signaling + networks: + - {{ talk_traefik_network }} + - {{ talk_internal_network }} + labels: + - traefik.enable=true + - traefik.docker.network={{ talk_traefik_network }} + + # Public WebSocket route (/spreed) + - traefik.http.routers.signal-public.rule=Host(`{{ talk_domain }}`) && PathPrefix(`/spreed`) + - traefik.http.routers.signal-public.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }} +{% if talk_use_ssl %} + - traefik.http.routers.signal-public.tls=true + - traefik.http.routers.signal-public.tls.certresolver={{ talk_cert_resolver }} +{% endif %} + - traefik.http.routers.signal-public.service=signal-svc + - traefik.http.routers.signal-public.middlewares=signal-ws + + # Public backend API route (/api/) + - traefik.http.routers.signal-backend.rule=Host(`{{ talk_domain }}`) && PathPrefix(`/api/`) + - traefik.http.routers.signal-backend.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }} +{% if talk_use_ssl %} + - traefik.http.routers.signal-backend.tls=true + - traefik.http.routers.signal-backend.tls.certresolver={{ talk_cert_resolver }} +{% endif %} + - traefik.http.routers.signal-backend.service=signal-svc + +{% if talk_internal_domain | length > 0 %} + # Internal split-horizon route (full host on int domain, WebSocket-aware) + - traefik.http.routers.signal-int.rule=Host(`{{ talk_internal_domain }}`) + - traefik.http.routers.signal-int.entrypoints={{ 'websecure' if talk_use_ssl else 'web' }} +{% if talk_use_ssl %} + - traefik.http.routers.signal-int.tls=true + - traefik.http.routers.signal-int.tls.certresolver={{ talk_cert_resolver }} +{% endif %} + - traefik.http.routers.signal-int.service=signal-svc + - traefik.http.routers.signal-int.middlewares=signal-ws +{% endif %} + + # Common service + - traefik.http.services.signal-svc.loadbalancer.server.port=8181 + + # WebSocket upgrade headers + - traefik.http.middlewares.signal-ws.headers.customrequestheaders.Upgrade=websocket + - traefik.http.middlewares.signal-ws.headers.customrequestheaders.Connection=Upgrade diff --git a/roles/talk/templates/janus.jcfg.j2 b/roles/talk/templates/janus.jcfg.j2 new file mode 100644 index 0000000..7c0a3bc --- /dev/null +++ b/roles/talk/templates/janus.jcfg.j2 @@ -0,0 +1,28 @@ +general: { + configs_folder = "/usr/local/etc/janus" + log_to_stdout = true +} + +nat: { + nat_1_1_mapping = "{{ talk_janus_public_ip }}" + ice_lite = {{ talk_janus_ice_lite | string | lower }} + ice_tcp = {{ talk_janus_ice_tcp | string | lower }} + + stun_server = "{{ talk_janus_stun_server }}" + stun_port = {{ talk_janus_stun_port }} + + rtp_port_range = "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}" +} + +media: { + rtp_port_range = "{{ talk_janus_rtp_port_min }}-{{ talk_janus_rtp_port_max }}" +} + +transports: { + websockets: { + ws = true + ws_port = 8188 + ws_interface = "0.0.0.0" + ws_ip = "0.0.0.0" + } +} diff --git a/roles/talk/templates/janus.logger.jcfg.j2 b/roles/talk/templates/janus.logger.jcfg.j2 new file mode 100644 index 0000000..6e1c4e4 --- /dev/null +++ b/roles/talk/templates/janus.logger.jcfg.j2 @@ -0,0 +1,3 @@ +general: { + enabled = true +} diff --git a/roles/talk/templates/janus.transport.websockets.jcfg.j2 b/roles/talk/templates/janus.transport.websockets.jcfg.j2 new file mode 100644 index 0000000..b5cb5a7 --- /dev/null +++ b/roles/talk/templates/janus.transport.websockets.jcfg.j2 @@ -0,0 +1,7 @@ +general: { + ws = true + ws_port = 8188 + ws_interface = "0.0.0.0" + ws_pingpong_trigger = 60 + ws_pingpong_timeout = 30 +} diff --git a/roles/talk/templates/server.conf.j2 b/roles/talk/templates/server.conf.j2 new file mode 100644 index 0000000..6d86c0a --- /dev/null +++ b/roles/talk/templates/server.conf.j2 @@ -0,0 +1,33 @@ +[http] +listen = 0.0.0.0:8181 +base_url = https://{{ talk_domain }} + +[backend] +backends = cloud + +[cloud] +secret = {{ talk_backend_secret }} +url = {{ talk_nextcloud_url }} + +[nats] +url = nats://nats:4222 + +[mcu] +type = {{ talk_mcu_type }} +url = ws://janus:8188/ + +[sessions] +hashkey = {{ talk_session_hashkey }} +blockkey = {{ talk_session_blockkey }} + +[turn] +servers = {{ talk_turn_servers }} +realm = {{ talk_turn_realm }} +{% if talk_turn_apikey | length > 0 %} +apikey = {{ talk_turn_apikey }} +{% endif %} +secret = {{ talk_turn_secret }} + +[app] +trustedproxies = {{ talk_trusted_proxies | join(',') }} +allowedhosts = {{ talk_allowed_hosts | join(',') }} diff --git a/roles/talk/tests/inventory b/roles/talk/tests/inventory new file mode 100644 index 0000000..eec845d --- /dev/null +++ b/roles/talk/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost \ No newline at end of file diff --git a/roles/talk/tests/test.yml b/roles/talk/tests/test.yml new file mode 100644 index 0000000..a3c7d07 --- /dev/null +++ b/roles/talk/tests/test.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - talk diff --git a/roles/talk/vars/main.yml b/roles/talk/vars/main.yml new file mode 100644 index 0000000..fedd529 --- /dev/null +++ b/roles/talk/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for httpbin From dc8f1e2ecd01c0eb6c69dad0a889297e1d0ab3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:03:04 +0200 Subject: [PATCH 13/19] fix(talk,coturn): correct vars file header (was 'httpbin') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both new roles had 'vars file for httpbin' as the header comment in vars/main.yml — copy-paste artefact from the httpbin role template. Files are otherwise empty. Reviewer flagged both inline (PR review comments 229 and 230). --- roles/coturn/vars/main.yml | 2 +- roles/talk/vars/main.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/roles/coturn/vars/main.yml b/roles/coturn/vars/main.yml index fedd529..f2a4ea3 100644 --- a/roles/coturn/vars/main.yml +++ b/roles/coturn/vars/main.yml @@ -1,3 +1,3 @@ #SPDX-License-Identifier: MIT-0 --- -# vars file for httpbin +# vars file for coturn diff --git a/roles/talk/vars/main.yml b/roles/talk/vars/main.yml index fedd529..a131766 100644 --- a/roles/talk/vars/main.yml +++ b/roles/talk/vars/main.yml @@ -1,3 +1,3 @@ #SPDX-License-Identifier: MIT-0 --- -# vars file for httpbin +# vars file for talk From 9a9039c4d39c7f4dc2ebf777898baa58d251062c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:03:23 +0200 Subject: [PATCH 14/19] docs(talk,coturn): add meta/argument_specs.yml * coturn: 31 typed options including the 3 cert modes (acme/file/ selfsigned), the RFC2136 acme.sh sidecar config and challenge alias subschema. coturn_static_auth_secret marked required. * talk: 34 typed options covering the signaling/janus/nats triplet, TURN integration, MCU (janus) tuning, trusted-proxy CIDRs and the extra_hosts pin. talk_backend_secret, talk_turn_secret, talk_session_hashkey and talk_session_blockkey marked required. Both specs load cleanly through ansible-core's ArgumentSpecValidator, have 100% defaults/spec coverage, and match the convention introduced for the other roles in this collection. --- roles/coturn/meta/argument_specs.yml | 148 ++++++++++++++++++++++++ roles/talk/meta/argument_specs.yml | 161 +++++++++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 roles/coturn/meta/argument_specs.yml create mode 100644 roles/talk/meta/argument_specs.yml diff --git a/roles/coturn/meta/argument_specs.yml b/roles/coturn/meta/argument_specs.yml new file mode 100644 index 0000000..55a9b3e --- /dev/null +++ b/roles/coturn/meta/argument_specs.yml @@ -0,0 +1,148 @@ +--- +argument_specs: + main: + short_description: Deploy a coturn TURN/STUN server with optional acme.sh sidecar. + description: + - "Renders a Docker Compose stack for coturn running in + C(network_mode: host), with an optional C(acme.sh) sidecar that + issues + renews a public TLS certificate via RFC2136 / nsupdate + and restarts coturn on renewal." + - Designed to be paired with the C(digitalboard.core.talk) role + (Nextcloud Talk High Performance Backend). + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + coturn_service_name: + type: str + default: coturn + coturn_docker_compose_dir: + type: path + coturn_docker_volume_dir: + type: path + + coturn_image: + type: str + default: "coturn/coturn:4.6.2-r5-alpine" + coturn_acme_image: + type: str + default: "neilpang/acme.sh:3.1.0" + + coturn_realm: + type: str + default: stun.example.test + description: Public DNS name used for the TURN realm and the public certificate. + coturn_internal_realm: + type: str + default: '' + description: + - Optional second DNS name issued on the same certificate, used for + split-horizon internal access (e.g. C(stun.int.example.test)). + + coturn_listening_port: + type: int + default: 3478 + description: TURN/STUN port (TCP + UDP). IANA standard is 3478. + coturn_tls_listening_port: + type: int + default: 5349 + description: TURNS port (TCP + UDP). IANA standard is 5349. + coturn_min_relay_port: + type: int + default: 49160 + coturn_max_relay_port: + type: int + default: 49200 + + coturn_external_ip: + type: str + default: '' + description: + - coturn C(--external-ip) value. Format C("PUBLIC_IP") or + C("PUBLIC_IP/PRIVATE_IP"). Must be set in host_vars for production. + coturn_listening_ip: + type: str + default: '0.0.0.0' + + coturn_static_auth_secret: + type: str + required: true + description: + - Shared secret used by the HPB signaling server to mint short-lived + TURN credentials. Default lookup reads + C(playbooks/secrets//coturn_static_auth_secret). + + coturn_extra_args: + type: list + elements: str + default: [] + description: Additional CLI flags appended verbatim to the container C(command:). + + coturn_cert_mode: + type: str + choices: [acme, file, selfsigned] + default: file + description: + - C(acme) runs an acme.sh sidecar that issues + renews via RFC2136 + and restarts coturn. C(file) assumes a certificate already lives + on the host (you manage it). C(selfsigned) generates one on first + run (vagrant/dev only). + coturn_cert_dir: + type: path + coturn_cert_file: + type: str + default: fullchain.cer + coturn_key_file: + type: str + description: Defaults to C("{{ coturn_realm }}.key"). + + coturn_acme_email: + type: str + default: admin@example.test + coturn_acme_directory: + type: str + default: https://acme-v02.api.letsencrypt.org/directory + coturn_acme_keylength: + type: str + default: ec-256 + choices: [ec-256, ec-384, '2048', '3072', '4096'] + coturn_acme_dnssleep: + type: int + default: 60 + coturn_acme_data_dir: + type: path + + coturn_acme_nsupdate_server: + type: str + default: '' + description: Authoritative nameserver acme.sh sends C(nsupdate) packets to. + coturn_acme_nsupdate_server_ip: + type: str + default: '' + description: Optional C(extra_hosts) pin (string IP) for the nsupdate server. + coturn_acme_nsupdate_zone: + type: str + default: '' + description: Delegated challenge zone (e.g. C(example._acme.example.test)). + coturn_acme_challenge_aliases: + type: list + elements: dict + default: [] + description: + - Per-name challenge alias zones (one entry per SAN). When empty, + built automatically as C({{ realm }}._acme.{{ zone-tail }}). + options: + name: + type: str + required: true + description: SAN the challenge is for. + alias: + type: str + required: true + description: CNAME target where the C(_acme-challenge) TXT lives. + coturn_acme_nsupdate_key_src: + type: path + description: Path of the TSIG key file on the controller, mounted into the acme container. diff --git a/roles/talk/meta/argument_specs.yml b/roles/talk/meta/argument_specs.yml new file mode 100644 index 0000000..9117ea8 --- /dev/null +++ b/roles/talk/meta/argument_specs.yml @@ -0,0 +1,161 @@ +--- +argument_specs: + main: + short_description: Deploy the Nextcloud Talk High Performance Backend (HPB) stack. + description: + - Renders a Docker Compose stack with C(nextcloud-spreed-signaling) + (Strukturag), C(janus-gateway) (canyan build) and C(nats) (internal + message broker) behind Traefik. + - Designed to be paired with the C(digitalboard.core.coturn) role + (TURN/STUN) and registered in Nextcloud via + C(digitalboard.core.nextcloud)'s C(talk.yml) task. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + talk_service_name: + type: str + default: signaling + talk_docker_compose_dir: + type: path + talk_docker_volume_dir: + type: path + + talk_signaling_image: + type: str + default: "strukturag/nextcloud-spreed-signaling:1.3.4" + talk_janus_image: + type: str + default: "canyan/janus-gateway:1.2.4" + talk_nats_image: + type: str + default: "nats:2.10-alpine" + + talk_traefik_network: + type: str + default: proxy + talk_internal_network: + type: str + default: hpb_internal + + talk_use_ssl: + type: bool + default: true + talk_cert_resolver: + type: str + default: dns + talk_domain: + type: str + default: signaling.example.test + description: Public domain (typically routed through the DMZ Traefik). + talk_internal_domain: + type: str + default: '' + description: + - Optional split-horizon C(*.int.*) domain for server-to-server + traffic (e.g. C(signaling.int.example.test)). + + talk_nextcloud_url: + type: str + default: https://cloud.example.test + description: Nextcloud base URL the HPB talks back to. Must be reachable from the HPB container. + talk_nextcloud_extra_host_ip: + type: str + default: '' + description: + - Pin the Nextcloud hostname to a backend IP via C(extra_hosts) to bypass + DMZ hairpin / SNI issues. Empty disables the pin. + + talk_backend_secret: + type: str + required: true + description: + - HMAC secret shared with Nextcloud Talk. Default lookup reads + C(playbooks/secrets//talk_backend_secret). + + talk_turn_secret: + type: str + required: true + description: + - Shared secret with coturn (must match C(coturn_static_auth_secret) + on the TURN host). Default lookup reads + C(playbooks/secrets//talk_turn_secret). + talk_turn_servers: + type: str + default: "turns:stun.example.test:5349?transport=tcp,turn:stun.example.test:3478" + description: + - TURN server URI list as understood by the signaling server. + Override to C(:443) when coturn binds on 443 in restrictive networks. + talk_turn_realm: + type: str + default: stun.example.test + talk_turn_apikey: + type: str + default: '' + description: Optional explicit API key; when empty a random one is generated on first run. + + talk_session_hashkey: + type: str + required: true + description: + - 32-byte hex string. Default lookup reads + C(playbooks/secrets//talk_session_hashkey). + talk_session_blockkey: + type: str + required: true + description: + - 32-byte hex string. Default lookup reads + C(playbooks/secrets//talk_session_blockkey). + + talk_mcu_type: + type: str + choices: [janus] + default: janus + talk_janus_public_ip: + type: str + default: '' + description: Must be set in host_vars. Goes into janus C(nat_1_1_mapping). + talk_janus_rtp_port_min: + type: int + default: 20000 + talk_janus_rtp_port_max: + type: int + default: 21000 + talk_janus_stun_server: + type: str + default: stun.int.example.test + description: STUN server janus uses for its own ICE candidate gathering. + talk_janus_stun_port: + type: int + default: 5349 + talk_janus_ice_lite: + type: bool + default: true + talk_janus_ice_tcp: + type: bool + default: true + + talk_trusted_proxies: + type: list + elements: str + default: + - "172.16.0.0/12" + - "192.168.0.0/16" + - "10.0.0.0/8" + talk_allowed_hosts: + type: list + elements: str + default: + - "172.16.0.0/12" + + talk_extra_hosts: + type: list + elements: str + default: [] + description: + - Extra C(host:ip) entries forwarded to all three containers. + Pre-populated with the Nextcloud pin when + C(talk_nextcloud_extra_host_ip) is set. From 4655c8f037a695d8f2666fddfd7fa0ef49db4607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Wed, 20 May 2026 22:00:32 +0200 Subject: [PATCH 15/19] feat(send): add role for self-hosted Send file-share service Deploys timvisee/send with a Redis backend behind Traefik. Supports local-disk or S3 storage (e.g. via the garage role). Uses the shared `*_domains` list convention so the router can accept internal *.int.* names alongside the canonical BASE_URL host. --- roles/send/README.md | 60 +++++++++++++++++++ roles/send/defaults/main.yml | 53 +++++++++++++++++ roles/send/handlers/main.yml | 9 +++ roles/send/meta/main.yml | 14 +++++ roles/send/tasks/main.yml | 28 +++++++++ roles/send/templates/docker-compose.yml.j2 | 69 ++++++++++++++++++++++ roles/send/vars/main.yml | 3 + 7 files changed, 236 insertions(+) create mode 100644 roles/send/README.md create mode 100644 roles/send/defaults/main.yml create mode 100644 roles/send/handlers/main.yml create mode 100644 roles/send/meta/main.yml create mode 100644 roles/send/tasks/main.yml create mode 100644 roles/send/templates/docker-compose.yml.j2 create mode 100644 roles/send/vars/main.yml diff --git a/roles/send/README.md b/roles/send/README.md new file mode 100644 index 0000000..339628b --- /dev/null +++ b/roles/send/README.md @@ -0,0 +1,60 @@ +Send +==== + +Deploys a self-hosted [Send](https://github.com/timvisee/send) instance +(timvisee fork of the discontinued Mozilla Send) with a Redis backend +behind Traefik, using Docker Compose. + +Requirements +------------ + +- Docker + `docker compose` plugin on the target host +- Traefik (role `digitalboard.core.traefik`) reachable via an external + Docker network named `proxy` (default) +- DNS for each entry in `send_domains` pointing at the reverse proxy +- Optional: a Garage S3 bucket if `send_storage_backend: s3` + +Role Variables +-------------- + +Important defaults (see `defaults/main.yml` for the full list): + +| Variable | Default | Description | +|---|---|---| +| `send_domains` | `["send.local.test"]` | FQDNs the router accepts; first entry is the canonical BASE_URL | +| `send_image` | `registry.gitlab.com/timvisee/send:latest` | Send container image | +| `send_max_file_size` | `1073741824` | Max upload size in bytes (1 GiB) | +| `send_max_expire_seconds` | `604800` | Max share lifetime (7 d) | +| `send_storage_backend` | `local` | `local` (volume) or `s3` | +| `send_s3_*` | `""` | S3 endpoint/bucket/key/secret (when backend is `s3`) | +| `send_use_ssl` | `true` | Issue Traefik labels for the `websecure` entrypoint | + +Dependencies +------------ + +None. + +Example Playbook +---------------- + +```yaml +- hosts: send_servers + become: true + roles: + - digitalboard.core.send +``` + +With S3 (Garage) backend: + +```yaml +send_storage_backend: s3 +send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domain'] }}" +send_s3_bucket: "send" +send_s3_access_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['key_id'] }}" +send_s3_secret_key: "{{ lookup('digitalboard.core.garage_credentials', 'send', host='backend')['secret_key'] }}" +``` + +License +------- + +MIT diff --git a/roles/send/defaults/main.yml b/roles/send/defaults/main.yml new file mode 100644 index 0000000..ba3aecc --- /dev/null +++ b/roles/send/defaults/main.yml @@ -0,0 +1,53 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for send + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# Send-specific configuration +send_service_name: send +send_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ send_service_name }}" +send_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ send_service_name }}" + +# Service configuration +# FQDNs the send router accepts. The first entry is the canonical +# domain (used as BASE_URL); further entries cover internal *.int.* +# names so backend uploads can hit us without hairpinning via DMZ. +send_domains: + - "send.local.test" +send_image: "registry.gitlab.com/timvisee/send:latest" +send_port: 1443 +send_extra_hosts: [] + +# Redis backend +send_redis_image: "redis:7-alpine" +send_redis_service_name: "send-redis" + +# Send application configuration +# https://github.com/timvisee/send/blob/master/server/config.js +send_max_file_size: 1073741824 # 1 GiB in bytes +send_default_downloads: 1 +send_max_downloads: 100 +send_default_expire_seconds: 86400 # 24h +send_max_expire_seconds: 604800 # 7d +send_max_files_per_archive: 64 +send_download_counts: "1,2,3,4,5,20,50,100" +send_expire_times_seconds: "300,3600,86400,604800" + +# Storage backend: "local" (volume) or "s3" +send_storage_backend: "local" + +# S3 backend (only used when send_storage_backend == "s3") +send_s3_endpoint: "" +send_s3_bucket: "" +send_s3_region: "us-east-1" +send_s3_access_key: "" +send_s3_secret_key: "" +send_s3_use_path_style: true + +# Traefik configuration +send_traefik_network: "proxy" +send_internal_network: "send_internal" +send_use_ssl: true diff --git a/roles/send/handlers/main.yml b/roles/send/handlers/main.yml new file mode 100644 index 0000000..cb83189 --- /dev/null +++ b/roles/send/handlers/main.yml @@ -0,0 +1,9 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for send + +- name: restart send + community.docker.docker_compose_v2: + project_src: "{{ send_docker_compose_dir }}" + state: present + recreate: always diff --git a/roles/send/meta/main.yml b/roles/send/meta/main.yml new file mode 100644 index 0000000..79dedb1 --- /dev/null +++ b/roles/send/meta/main.yml @@ -0,0 +1,14 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: digitalboard + description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose + license: MIT + + min_ansible_version: 2.14 + + galaxy_tags: + - send + - filesharing + - docker + +dependencies: [] diff --git a/roles/send/tasks/main.yml b/roles/send/tasks/main.yml new file mode 100644 index 0000000..c79405a --- /dev/null +++ b/roles/send/tasks/main.yml @@ -0,0 +1,28 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for send + +- name: Create docker compose directory + file: + path: "{{ send_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create local upload directory + file: + path: "{{ send_docker_volume_dir }}/uploads" + state: directory + mode: '0755' + when: send_storage_backend == "local" + +- name: Create docker-compose file for send + template: + src: docker-compose.yml.j2 + dest: "{{ send_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + notify: restart send + +- name: Start send container + community.docker.docker_compose_v2: + project_src: "{{ send_docker_compose_dir }}" + state: present diff --git a/roles/send/templates/docker-compose.yml.j2 b/roles/send/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..a6733bb --- /dev/null +++ b/roles/send/templates/docker-compose.yml.j2 @@ -0,0 +1,69 @@ +services: + {{ send_service_name }}: + image: {{ send_image }} + container_name: {{ send_service_name }} + restart: unless-stopped + depends_on: + - {{ send_redis_service_name }} + networks: + - {{ send_traefik_network }} + - {{ send_internal_network }} +{% if send_extra_hosts is defined and send_extra_hosts | length > 0 %} + extra_hosts: +{% for host in send_extra_hosts %} + - "{{ host }}" +{% endfor %} +{% endif %} + environment: +{% if send_use_ssl %} + BASE_URL: "https://{{ send_domains[0] }}" +{% else %} + BASE_URL: "http://{{ send_domains[0] }}" +{% endif %} + REDIS_HOST: "{{ send_redis_service_name }}" + REDIS_PORT: "6379" + MAX_FILE_SIZE: "{{ send_max_file_size }}" + DEFAULT_DOWNLOADS: "{{ send_default_downloads }}" + MAX_DOWNLOADS: "{{ send_max_downloads }}" + DEFAULT_EXPIRE_SECONDS: "{{ send_default_expire_seconds }}" + MAX_EXPIRE_SECONDS: "{{ send_max_expire_seconds }}" + MAX_FILES_PER_ARCHIVE: "{{ send_max_files_per_archive }}" + DOWNLOAD_COUNTS: "{{ send_download_counts }}" + EXPIRE_TIMES_SECONDS: "{{ send_expire_times_seconds }}" +{% if send_storage_backend == "s3" %} + S3_BUCKET: "{{ send_s3_bucket }}" + S3_ENDPOINT: "{{ send_s3_endpoint }}" + S3_USE_PATH_STYLE_ENDPOINT: "{{ 'true' if send_s3_use_path_style else 'false' }}" + AWS_ACCESSKEYID: "{{ send_s3_access_key }}" + AWS_SECRETACCESSKEY: "{{ send_s3_secret_key }}" + AWS_REGION: "{{ send_s3_region }}" +{% else %} + FILE_DIR: "/uploads" + volumes: + - {{ send_docker_volume_dir }}/uploads:/uploads +{% endif %} + labels: + - traefik.enable=true + - traefik.docker.network={{ send_traefik_network }} + - traefik.http.routers.{{ send_service_name }}.rule=Host({% for d in send_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) + - traefik.http.services.{{ send_service_name }}.loadbalancer.server.port={{ send_port }} +{% if send_use_ssl %} + - traefik.http.routers.{{ send_service_name }}.entrypoints=websecure + - traefik.http.routers.{{ send_service_name }}.tls=true +{% else %} + - traefik.http.routers.{{ send_service_name }}.entrypoints=web +{% endif %} + + {{ send_redis_service_name }}: + image: {{ send_redis_image }} + container_name: {{ send_redis_service_name }} + restart: unless-stopped + networks: + - {{ send_internal_network }} + volumes: + - {{ send_docker_volume_dir }}/redis:/data + +networks: + {{ send_internal_network }}: + {{ send_traefik_network }}: + external: true diff --git a/roles/send/vars/main.yml b/roles/send/vars/main.yml new file mode 100644 index 0000000..b2a6b30 --- /dev/null +++ b/roles/send/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for send From e1d604effc9f9441f9b63e6a8fc7bf6917c78022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:06:58 +0200 Subject: [PATCH 16/19] fix(send): self-review fixes (FQCN, min_ansible_version str) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * tasks/main.yml: prefix all builtin modules with ansible.builtin (file, template) — silences ansible-lint fqcn[action-core] and matches the convention used by the other roles in this collection. * meta/main.yml: change min_ansible_version from the float 2.14 to the string '2.14'. ansible-galaxy's schema requires a string here (ansible-lint schema[meta] complains otherwise — same fix I just applied to the opnform role). --- roles/send/meta/main.yml | 2 +- roles/send/tasks/main.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/roles/send/meta/main.yml b/roles/send/meta/main.yml index 79dedb1..20f9e67 100644 --- a/roles/send/meta/main.yml +++ b/roles/send/meta/main.yml @@ -4,7 +4,7 @@ galaxy_info: description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose license: MIT - min_ansible_version: 2.14 + min_ansible_version: "2.14" galaxy_tags: - send diff --git a/roles/send/tasks/main.yml b/roles/send/tasks/main.yml index c79405a..9ed8dd8 100644 --- a/roles/send/tasks/main.yml +++ b/roles/send/tasks/main.yml @@ -3,20 +3,20 @@ # tasks file for send - name: Create docker compose directory - file: + ansible.builtin.file: path: "{{ send_docker_compose_dir }}" state: directory mode: '0755' - name: Create local upload directory - file: + ansible.builtin.file: path: "{{ send_docker_volume_dir }}/uploads" state: directory mode: '0755' when: send_storage_backend == "local" - name: Create docker-compose file for send - template: + ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ send_docker_compose_dir }}/docker-compose.yml" mode: '0644' From b19ac2270a0983bc4649dd220a7f1327b4ce4b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:07:07 +0200 Subject: [PATCH 17/19] fix(send): use Traefik v3 OR-syntax for multi-domain Host rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router rule joined send_domains with ', ' which is the v2 syntax ('Host(`a`, `b`)'). Traefik v3 expects each Host() to be its own matcher joined with the explicit '||' OR operator. With v3 the comma form is silently ignored — only the first host actually matches. Match the pattern already used in the authentik, drawio and nextcloud roles in this collection. --- roles/send/templates/docker-compose.yml.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/send/templates/docker-compose.yml.j2 b/roles/send/templates/docker-compose.yml.j2 index a6733bb..69a43ab 100644 --- a/roles/send/templates/docker-compose.yml.j2 +++ b/roles/send/templates/docker-compose.yml.j2 @@ -45,7 +45,7 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ send_traefik_network }} - - traefik.http.routers.{{ send_service_name }}.rule=Host({% for d in send_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) + - traefik.http.routers.{{ send_service_name }}.rule={% for d in send_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} - traefik.http.services.{{ send_service_name }}.loadbalancer.server.port={{ send_port }} {% if send_use_ssl %} - traefik.http.routers.{{ send_service_name }}.entrypoints=websecure From a492c3ee049b6d1023f2baca17a280b6cc78796a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:07:17 +0200 Subject: [PATCH 18/19] docs(send): add meta/argument_specs.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 29 typed options with full defaults coverage (no required: true marks — the role works with an empty S3 config when storage_backend=local). Documents the send_domains list convention, the local-vs-s3 storage choice, the timing/size limits and the Traefik / network wiring. Loads through ansible-core's ArgumentSpecValidator. Matches the spec convention used by the other roles in this collection. --- roles/send/meta/argument_specs.yml | 122 +++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 roles/send/meta/argument_specs.yml diff --git a/roles/send/meta/argument_specs.yml b/roles/send/meta/argument_specs.yml new file mode 100644 index 0000000..2e9797e --- /dev/null +++ b/roles/send/meta/argument_specs.yml @@ -0,0 +1,122 @@ +--- +argument_specs: + main: + short_description: Deploy timvisee/send (file-sharing) with a Redis backend via Docker Compose. + description: + - Renders a Compose stack with the C(timvisee/send) container and a + Redis companion behind Traefik. Storage can be local-disk or any + S3-compatible backend (e.g. the C(garage) role). + - Uses the shared C(*_domains) list convention so the router can + accept internal C(*.int.*) hostnames alongside the canonical + BASE_URL host. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + send_service_name: + type: str + default: send + send_docker_compose_dir: + type: path + send_docker_volume_dir: + type: path + + send_domains: + type: list + elements: str + default: ['send.local.test'] + description: + - FQDNs the router accepts. First entry is the canonical hostname + and is used as C(BASE_URL). Further entries cover internal + C(*.int.*) names so backend uploads can hit Send without + hairpinning via the DMZ. + send_image: + type: str + default: "registry.gitlab.com/timvisee/send:latest" + send_port: + type: int + default: 1443 + send_extra_hosts: + type: list + elements: str + default: [] + description: C(extra_hosts) entries injected into the send container (Docker C(host:ip) syntax). + + send_redis_image: + type: str + default: "redis:7-alpine" + send_redis_service_name: + type: str + default: send-redis + + send_max_file_size: + type: int + default: 1073741824 + description: Max upload size in bytes. Default is 1 GiB. + send_default_downloads: + type: int + default: 1 + send_max_downloads: + type: int + default: 100 + send_default_expire_seconds: + type: int + default: 86400 + description: Default share lifetime in seconds (24 h). + send_max_expire_seconds: + type: int + default: 604800 + description: Maximum share lifetime in seconds (7 d). + send_max_files_per_archive: + type: int + default: 64 + send_download_counts: + type: str + default: "1,2,3,4,5,20,50,100" + description: Comma-separated list of download-count options shown in the UI. + send_expire_times_seconds: + type: str + default: "300,3600,86400,604800" + description: Comma-separated list of expire-time options (seconds) shown in the UI. + + send_storage_backend: + type: str + choices: [local, s3] + default: local + description: + - C(local) keeps uploads in a host volume. C(s3) uses an + S3-compatible backend (any of the C(send_s3_*) variables is + required when this is set). + + send_s3_endpoint: + type: str + default: '' + send_s3_bucket: + type: str + default: '' + send_s3_region: + type: str + default: us-east-1 + send_s3_access_key: + type: str + default: '' + send_s3_secret_key: + type: str + default: '' + send_s3_use_path_style: + type: bool + default: true + description: Required for most non-AWS S3-compatible backends (Garage, MinIO). + + send_traefik_network: + type: str + default: proxy + send_internal_network: + type: str + default: send_internal + send_use_ssl: + type: bool + default: true From c11f019aae69c3c7cac83dac061040be5ce2a9b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 15:40:21 +0200 Subject: [PATCH 19/19] fix(send): assert S3 credentials when storage backend is s3 When send_storage_backend=s3 the role previously deployed the container with whatever was in send_s3_* (often empty strings from the defaults). The container would then start, accept uploads, and fail to persist anything silently. Same pattern as the validate blocks in coturn, talk, bookstack and opnform: fail fast at task time with a clear error that points at the four missing variables. Skipped entirely when send_storage_backend=local (the default). --- roles/send/tasks/main.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/roles/send/tasks/main.yml b/roles/send/tasks/main.yml index 9ed8dd8..2ed91c9 100644 --- a/roles/send/tasks/main.yml +++ b/roles/send/tasks/main.yml @@ -2,6 +2,20 @@ --- # 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 }}"