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 01/29] 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 02/29] 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 03/29] 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 04/29] 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 05/29] 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 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 06/29] 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 07/29] 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 08/29] 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 09/29] 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 10/29] 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 11/29] 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 12/29] 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 13/29] 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 14/29] 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 15/29] 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 }}" From c3cf779532c0a52d3c5df46e3b195b6e2786fb0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Wed, 20 May 2026 22:13:34 +0200 Subject: [PATCH 16/29] feat: domain list refactor + demo-gymburgdorf fixes - Refactor: collapse `*_domain` + `*_extra_domains` into a single `*_domains` list across authentik, collabora, garage and nextcloud roles. First entry is the canonical FQDN (used for OVERWRITEHOST, BASE_URL, notify_push setup and garage root_domain). - Authentik blueprint: guard the OAuth sources block so an empty `authentik_login_sources` no longer renders an invalid YAML key. - Nextcloud: introduce `nextcloud_collabora_public_domain` and set Collabora's `public_wopi_url` separately from the server-to-server `wopi_url` so browsers can reach Collabora via the public name while Nextcloud still talks to it on the internal one. - Nextcloud: URL-encode the postgres user/password in DATABASE_URL. --- roles/authentik/defaults/main.yml | 6 ++++- .../blueprint-login-sources.yaml.j2 | 2 ++ .../authentik/templates/docker-compose.yml.j2 | 5 ++++- roles/collabora/defaults/main.yml | 6 ++++- .../collabora/templates/docker-compose.yml.j2 | 5 ++++- roles/garage/defaults/main.yml | 6 ++++- roles/garage/templates/docker-compose.yml.j2 | 8 ++++++- roles/garage/templates/garage.toml.j2 | 2 +- roles/nextcloud/defaults/main.yml | 7 +++++- roles/nextcloud/tasks/collabora.yml | 8 ++++++- roles/nextcloud/tasks/notify_push.yml | 2 +- .../nextcloud/templates/docker-compose.yml.j2 | 22 ++++++++++++++----- 12 files changed, 64 insertions(+), 15 deletions(-) diff --git a/roles/authentik/defaults/main.yml b/roles/authentik/defaults/main.yml index 9b2ca9a..3ff71be 100644 --- a/roles/authentik/defaults/main.yml +++ b/roles/authentik/defaults/main.yml @@ -12,7 +12,11 @@ authentik_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ authentik_servic authentik_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ authentik_service_name }}" # Authentik service configuration -authentik_domain: "authentik.local.test" +# FQDNs the authentik router accepts. The first entry is the canonical +# domain; further entries cover internal *.int.* names used for +# server-to-server traffic so backend calls don't hairpin via DMZ. +authentik_domains: + - "authentik.local.test" authentik_image: "ghcr.io/goauthentik/server:2026.2.2" authentik_port: 9000 authentik_secret_key: "changeme-generate-a-random-string" diff --git a/roles/authentik/templates/blueprints/blueprint-login-sources.yaml.j2 b/roles/authentik/templates/blueprints/blueprint-login-sources.yaml.j2 index acbb635..8bddb41 100644 --- a/roles/authentik/templates/blueprints/blueprint-login-sources.yaml.j2 +++ b/roles/authentik/templates/blueprints/blueprint-login-sources.yaml.j2 @@ -16,8 +16,10 @@ entries: {% for field in authentik_login_user_fields %} - {{ field }} {% endfor %} +{% if authentik_login_sources %} # OAuth/social login sources (use !Find to reference sources from other blueprints) sources: {% for src in authentik_login_sources %} - !Find [authentik_sources_oauth.oauthsource, [slug, {{ src.slug }}]] {% endfor %} +{% endif %} diff --git a/roles/authentik/templates/docker-compose.yml.j2 b/roles/authentik/templates/docker-compose.yml.j2 index c9796a2..f4a1f95 100644 --- a/roles/authentik/templates/docker-compose.yml.j2 +++ b/roles/authentik/templates/docker-compose.yml.j2 @@ -48,10 +48,13 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ authentik_traefik_network }} - - traefik.http.routers.{{ authentik_service_name }}.rule=Host(`{{ authentik_domain }}`) + - traefik.http.routers.{{ authentik_service_name }}.rule=Host({% for d in authentik_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) {% if authentik_use_ssl %} - traefik.http.routers.{{ authentik_service_name }}.entrypoints=websecure - traefik.http.routers.{{ authentik_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ authentik_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ authentik_service_name }}.entrypoints=web {% endif %} diff --git a/roles/collabora/defaults/main.yml b/roles/collabora/defaults/main.yml index 3cfb559..11aa468 100644 --- a/roles/collabora/defaults/main.yml +++ b/roles/collabora/defaults/main.yml @@ -12,7 +12,11 @@ collabora_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ collabora_servic collabora_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ collabora_service_name }}" # Service configuration -collabora_domain: "office.local.test" +# FQDNs the collabora router accepts. The first entry is the canonical +# domain; further entries cover internal *.int.* names used for +# server-to-server WOPI discovery. +collabora_domains: + - "office.local.test" collabora_image: "collabora/code:latest" collabora_port: 9980 collabora_extra_hosts: [] diff --git a/roles/collabora/templates/docker-compose.yml.j2 b/roles/collabora/templates/docker-compose.yml.j2 index c0f589e..af6ecfc 100644 --- a/roles/collabora/templates/docker-compose.yml.j2 +++ b/roles/collabora/templates/docker-compose.yml.j2 @@ -20,11 +20,14 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ collabora_traefik_network }} - - traefik.http.routers.{{ collabora_service_name }}.rule=Host(`{{ collabora_domain }}`) + - traefik.http.routers.{{ collabora_service_name }}.rule=Host({% for d in collabora_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) - traefik.http.services.{{ collabora_service_name }}.loadbalancer.server.port={{ collabora_port }} {% if collabora_use_ssl %} - traefik.http.routers.{{ collabora_service_name }}.entrypoints=websecure - traefik.http.routers.{{ collabora_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ collabora_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ collabora_service_name }}.entrypoints=web {% endif %} diff --git a/roles/garage/defaults/main.yml b/roles/garage/defaults/main.yml index 495317e..091e318 100644 --- a/roles/garage/defaults/main.yml +++ b/roles/garage/defaults/main.yml @@ -13,7 +13,11 @@ garage_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ garage_service_name } # Garage service configuration garage_image: "dxflrs/garage:v2.1.0" -garage_s3_domain: "storage.local.test" +# FQDNs the garage S3 router accepts. The first entry is the canonical +# domain and is also used as the virtual-hosted-style root_domain in +# garage.toml; further entries cover internal *.int.* names. +garage_s3_domains: + - "storage.local.test" garage_web_domain: "web.storage.local.test" garage_webui_domain: "console.storage.local.test" diff --git a/roles/garage/templates/docker-compose.yml.j2 b/roles/garage/templates/docker-compose.yml.j2 index 9e3e862..0427fb2 100644 --- a/roles/garage/templates/docker-compose.yml.j2 +++ b/roles/garage/templates/docker-compose.yml.j2 @@ -14,10 +14,13 @@ services: - traefik.enable=true - traefik.docker.network={{ garage_traefik_network }} # S3 API endpoint - - traefik.http.routers.{{ garage_service_name }}.rule=Host(`{{ garage_s3_domain }}`) + - traefik.http.routers.{{ garage_service_name }}.rule=Host({% for d in garage_s3_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) {% if garage_use_ssl %} - traefik.http.routers.{{ garage_service_name }}.entrypoints=websecure - traefik.http.routers.{{ garage_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ garage_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ garage_service_name }}.entrypoints=web {% endif %} @@ -48,6 +51,9 @@ services: {% if garage_use_ssl %} - traefik.http.routers.{{ garage_service_name }}-console.entrypoints=websecure - traefik.http.routers.{{ garage_service_name }}-console.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ garage_service_name }}-console.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ garage_service_name }}-console.entrypoints=web {% endif %} diff --git a/roles/garage/templates/garage.toml.j2 b/roles/garage/templates/garage.toml.j2 index 897ebcd..06b1164 100644 --- a/roles/garage/templates/garage.toml.j2 +++ b/roles/garage/templates/garage.toml.j2 @@ -14,7 +14,7 @@ rpc_secret = "{{ garage_rpc_secret }}" [s3_api] s3_region = "{{ garage_s3_region }}" api_bind_addr = "[::]:{{ garage_s3_api_port }}" -root_domain = ".s3.{{ garage_s3_domain }}" +root_domain = ".s3.{{ garage_s3_domains[0] }}" [s3_web] bind_addr = "[::]:{{ garage_s3_web_port }}" diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index 0c96046..c289d96 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -9,7 +9,12 @@ nextcloud_service_name: nextcloud nextcloud_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ nextcloud_service_name }}" nextcloud_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ nextcloud_service_name }}" -nextcloud_domain: "nextcloud.local.test" +# FQDNs the nextcloud router accepts. The first entry is the canonical +# domain (used for OVERWRITEHOST and the notify_push setup); further +# entries cover internal *.int.* names so collabora's WOPI callback +# hits us on a name with a valid cert. +nextcloud_domains: + - "nextcloud.local.test" nextcloud_image: "nextcloud:fpm" nextcloud_redis_image: "redis:latest" nextcloud_port: 80 diff --git a/roles/nextcloud/tasks/collabora.yml b/roles/nextcloud/tasks/collabora.yml index 05c56e4..2a7bd82 100644 --- a/roles/nextcloud/tasks/collabora.yml +++ b/roles/nextcloud/tasks/collabora.yml @@ -1,11 +1,17 @@ #SPDX-License-Identifier: MIT-0 --- # tasks file for configuring Collabora in Nextcloud -- name: Configure Collabora WOPI URL +- name: Configure Collabora WOPI URL (server-to-server) community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" command: php /var/www/html/occ config:app:set richdocuments wopi_url --value=https://{{ nextcloud_collabora_domain }} +- name: Configure Collabora public WOPI URL (browser-facing) + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ config:app:set richdocuments public_wopi_url --value=https://{{ nextcloud_collabora_public_domain }} + when: nextcloud_collabora_public_domain is defined and nextcloud_collabora_public_domain != nextcloud_collabora_domain + - name: Configure certificate verification for Collabora community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" diff --git a/roles/nextcloud/tasks/notify_push.yml b/roles/nextcloud/tasks/notify_push.yml index 18dbb8b..ccf2b72 100644 --- a/roles/nextcloud/tasks/notify_push.yml +++ b/roles/nextcloud/tasks/notify_push.yml @@ -5,4 +5,4 @@ - name: Configure notify_push base endpoint community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" - command: php /var/www/html/occ notify_push:setup https://{{ nextcloud_domain }}/push \ No newline at end of file + command: php /var/www/html/occ notify_push:setup https://{{ nextcloud_domains[0] }}/push \ No newline at end of file diff --git a/roles/nextcloud/templates/docker-compose.yml.j2 b/roles/nextcloud/templates/docker-compose.yml.j2 index 9f15760..0e05090 100644 --- a/roles/nextcloud/templates/docker-compose.yml.j2 +++ b/roles/nextcloud/templates/docker-compose.yml.j2 @@ -35,10 +35,13 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ nextcloud_traefik_network }} - - traefik.http.routers.{{ nextcloud_service_name }}.rule=Host(`{{ nextcloud_domain }}`) + - traefik.http.routers.{{ nextcloud_service_name }}.rule=Host({% for d in nextcloud_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) {% if nextcloud_use_ssl %} - traefik.http.routers.{{ nextcloud_service_name }}.entrypoints=websecure - traefik.http.routers.{{ nextcloud_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ nextcloud_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ nextcloud_service_name }}.entrypoints=web {% endif %} @@ -60,7 +63,7 @@ services: PHP_MEMORY_LIMIT: {{ nextcloud_memory_limit_mb }}M PHP_UPLOAD_LIMIT: {{ nextcloud_upload_limit_mb }}M OVERWRITEPROTOCOL: https - OVERWRITEHOST: {{ nextcloud_domain }} + OVERWRITEHOST: {{ nextcloud_domains[0] }} TRUSTED_PROXIES: "{{ nextcloud_trusted_proxies }}" volumes: - {{ nextcloud_docker_volume_dir }}/nextcloud/:/var/www/html @@ -69,6 +72,12 @@ services: {% for net in nextcloud_extra_networks %} - {{ net }} {% endfor %} +{% if nextcloud_extra_hosts is defined and nextcloud_extra_hosts | length > 0 %} + extra_hosts: +{% for host in nextcloud_extra_hosts %} + - "{{ host }}" +{% endfor %} +{% endif %} nextcloud: image: {{ nextcloud_image }} @@ -88,7 +97,7 @@ services: PHP_MEMORY_LIMIT: {{ nextcloud_memory_limit_mb }}M PHP_UPLOAD_LIMIT: {{ nextcloud_upload_limit_mb }}M OVERWRITEPROTOCOL: https - OVERWRITEHOST: {{ nextcloud_domain }} + OVERWRITEHOST: {{ nextcloud_domains[0] }} TRUSTED_PROXIES: "{{ nextcloud_trusted_proxies }}" {% if nextcloud_use_s3_storage %} OBJECTSTORE_S3_KEY: {{ nextcloud_s3_key }} @@ -127,7 +136,7 @@ services: environment: PORT: "7867" REDIS_URL: "redis://redis:6379" - DATABASE_URL: "postgres://{{ nextcloud_postgres_user }}:{{ nextcloud_postgres_password }}@db:5432/{{ nextcloud_postgres_db }}" + DATABASE_URL: "postgres://{{ nextcloud_postgres_user | urlencode }}:{{ nextcloud_postgres_password | urlencode }}@db:5432/{{ nextcloud_postgres_db }}" DATABASE_PREFIX: "oc_" NEXTCLOUD_URL: "http://nginx" networks: @@ -136,11 +145,14 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ nextcloud_traefik_network }} - - traefik.http.routers.{{ nextcloud_service_name }}-push.rule=Host(`{{ nextcloud_domain }}`) && PathPrefix(`/push`) + - traefik.http.routers.{{ nextcloud_service_name }}-push.rule=Host(`{{ nextcloud_domains[0] }}`) && PathPrefix(`/push`) - traefik.http.services.{{ nextcloud_service_name }}-push.loadbalancer.server.port=7867 {% if nextcloud_use_ssl %} - traefik.http.routers.{{ nextcloud_service_name }}-push.entrypoints=websecure - traefik.http.routers.{{ nextcloud_service_name }}-push.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ nextcloud_service_name }}-push.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ nextcloud_service_name }}-push.entrypoints=web {% endif %} From 2104e5fe7da83bed0c79f1ba10b09ee9fcf62418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Wed, 20 May 2026 22:44:41 +0200 Subject: [PATCH 17/29] feat: drop blanket recreates, ACME-DNS knobs, notify_push override - Drop `recreate: always` from collabora/drawio/homarr/opencloud/traefik handlers and the authentik_outpost_ldap start task. `up -d` with `state: present` already recreates exactly the services whose compose definition changed; the blanket recreate was forcing restarts even when nothing relevant moved. - Rewrite the `*_domains` Traefik Host loop to the `Host(\`a\`) || Host(\`b\`)` form across authentik/collabora/garage/nextcloud so the rule still matches when traefik can't normalize the comma-form into the same canonical shape. - Traefik: add `traefik_acme_tcp_only` (sets LEGO_EXPERIMENTAL_DNS_TCP_ONLY) and `traefik_acme_disable_ans_checks` (disables lego's authoritative-NS propagation check) for environments where the DNS path between the traefik container and the zone's nameservers is constrained. - Traefik DMZ collector: two-step merge so a `traefik_dmz_exposed_services` entry that sets its own `backend_host` wins over the host fallback; lets a route target an internal FQDN covered by the backend cert's SANs instead of the raw IP. - Nextcloud: add `nextcloud_notify_push_domain` override for the `occ notify_push:setup` call so the setup check can hit an internal FQDN instead of hairpinning through the DMZ. Push router now matches every entry in `nextcloud_domains`. - Nextcloud: also %2F-escape slashes in the postgres user/password inside the notify_push DATABASE_URL. --- roles/authentik/templates/docker-compose.yml.j2 | 2 +- roles/collabora/handlers/main.yml | 2 +- roles/collabora/templates/docker-compose.yml.j2 | 2 +- roles/drawio/handlers/main.yml | 2 +- roles/garage/templates/docker-compose.yml.j2 | 2 +- roles/homarr/handlers/main.yml | 2 +- roles/nextcloud/defaults/main.yml | 6 ++++++ roles/nextcloud/tasks/notify_push.yml | 2 +- roles/nextcloud/templates/docker-compose.yml.j2 | 6 +++--- roles/opencloud/handlers/main.yml | 2 +- roles/traefik/defaults/main.yml | 12 ++++++++++++ roles/traefik/handlers/main.yml | 2 +- roles/traefik/tasks/main.yml | 13 ++++++++++++- roles/traefik/templates/docker-compose.yml.j2 | 3 +++ roles/traefik/templates/traefik.yml.j2 | 4 ++++ 15 files changed, 49 insertions(+), 13 deletions(-) diff --git a/roles/authentik/templates/docker-compose.yml.j2 b/roles/authentik/templates/docker-compose.yml.j2 index f4a1f95..f0193ec 100644 --- a/roles/authentik/templates/docker-compose.yml.j2 +++ b/roles/authentik/templates/docker-compose.yml.j2 @@ -48,7 +48,7 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ authentik_traefik_network }} - - traefik.http.routers.{{ authentik_service_name }}.rule=Host({% for d in authentik_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) + - traefik.http.routers.{{ authentik_service_name }}.rule={% for d in authentik_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} {% if authentik_use_ssl %} - traefik.http.routers.{{ authentik_service_name }}.entrypoints=websecure - traefik.http.routers.{{ authentik_service_name }}.tls=true diff --git a/roles/collabora/handlers/main.yml b/roles/collabora/handlers/main.yml index bfd2b02..3364bcc 100644 --- a/roles/collabora/handlers/main.yml +++ b/roles/collabora/handlers/main.yml @@ -5,4 +5,4 @@ - name: restart collabora community.docker.docker_compose_v2: project_src: "{{ collabora_docker_compose_dir }}" - state: restarted \ No newline at end of file + state: present \ No newline at end of file diff --git a/roles/collabora/templates/docker-compose.yml.j2 b/roles/collabora/templates/docker-compose.yml.j2 index af6ecfc..353a08b 100644 --- a/roles/collabora/templates/docker-compose.yml.j2 +++ b/roles/collabora/templates/docker-compose.yml.j2 @@ -20,7 +20,7 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ collabora_traefik_network }} - - traefik.http.routers.{{ collabora_service_name }}.rule=Host({% for d in collabora_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) + - traefik.http.routers.{{ collabora_service_name }}.rule={% for d in collabora_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} - traefik.http.services.{{ collabora_service_name }}.loadbalancer.server.port={{ collabora_port }} {% if collabora_use_ssl %} - traefik.http.routers.{{ collabora_service_name }}.entrypoints=websecure diff --git a/roles/drawio/handlers/main.yml b/roles/drawio/handlers/main.yml index f1ef0da..b68633d 100644 --- a/roles/drawio/handlers/main.yml +++ b/roles/drawio/handlers/main.yml @@ -5,4 +5,4 @@ - name: restart drawio community.docker.docker_compose_v2: project_src: "{{ drawio_docker_compose_dir }}" - state: restarted \ No newline at end of file + state: present \ No newline at end of file diff --git a/roles/garage/templates/docker-compose.yml.j2 b/roles/garage/templates/docker-compose.yml.j2 index 0427fb2..d344e5f 100644 --- a/roles/garage/templates/docker-compose.yml.j2 +++ b/roles/garage/templates/docker-compose.yml.j2 @@ -14,7 +14,7 @@ services: - traefik.enable=true - traefik.docker.network={{ garage_traefik_network }} # S3 API endpoint - - traefik.http.routers.{{ garage_service_name }}.rule=Host({% for d in garage_s3_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) + - traefik.http.routers.{{ garage_service_name }}.rule={% for d in garage_s3_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} {% if garage_use_ssl %} - traefik.http.routers.{{ garage_service_name }}.entrypoints=websecure - traefik.http.routers.{{ garage_service_name }}.tls=true diff --git a/roles/homarr/handlers/main.yml b/roles/homarr/handlers/main.yml index dedb949..da74ed4 100644 --- a/roles/homarr/handlers/main.yml +++ b/roles/homarr/handlers/main.yml @@ -5,4 +5,4 @@ - name: restart homarr community.docker.docker_compose_v2: project_src: "{{ homarr_docker_compose_dir }}" - state: restarted \ No newline at end of file + state: present \ No newline at end of file diff --git a/roles/nextcloud/defaults/main.yml b/roles/nextcloud/defaults/main.yml index c289d96..a5decb1 100644 --- a/roles/nextcloud/defaults/main.yml +++ b/roles/nextcloud/defaults/main.yml @@ -65,6 +65,12 @@ nextcloud_trusted_proxies: "172.16.0.0/12" # File locking and real-time push notifications nextcloud_enable_notify_push: false nextcloud_notify_push_image: "icewind1991/notify_push:1.3.1" +# Domain used when calling `occ notify_push:setup`. Defaults to the +# first nextcloud_domains entry (the canonical public name). Override +# with an internal FQDN to avoid hairpinning the setup check through +# the DMZ; the FQDN must also be in nextcloud_domains so the push +# router matches it. +# nextcloud_notify_push_domain: "cloud.int.example.com" # Nextcloud Talk: register external HPB signaling + TURN + STUN # Set to true to run tasks/talk.yml after Nextcloud is up. diff --git a/roles/nextcloud/tasks/notify_push.yml b/roles/nextcloud/tasks/notify_push.yml index ccf2b72..1497c68 100644 --- a/roles/nextcloud/tasks/notify_push.yml +++ b/roles/nextcloud/tasks/notify_push.yml @@ -5,4 +5,4 @@ - name: Configure notify_push base endpoint community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" - command: php /var/www/html/occ notify_push:setup https://{{ nextcloud_domains[0] }}/push \ No newline at end of file + command: php /var/www/html/occ notify_push:setup https://{{ nextcloud_notify_push_domain | default(nextcloud_domains[0]) }}/push \ No newline at end of file diff --git a/roles/nextcloud/templates/docker-compose.yml.j2 b/roles/nextcloud/templates/docker-compose.yml.j2 index 0e05090..365a766 100644 --- a/roles/nextcloud/templates/docker-compose.yml.j2 +++ b/roles/nextcloud/templates/docker-compose.yml.j2 @@ -35,7 +35,7 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ nextcloud_traefik_network }} - - traefik.http.routers.{{ nextcloud_service_name }}.rule=Host({% for d in nextcloud_domains %}`{{ d }}`{% if not loop.last %}, {% endif %}{% endfor %}) + - traefik.http.routers.{{ nextcloud_service_name }}.rule={% for d in nextcloud_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} {% if nextcloud_use_ssl %} - traefik.http.routers.{{ nextcloud_service_name }}.entrypoints=websecure - traefik.http.routers.{{ nextcloud_service_name }}.tls=true @@ -136,7 +136,7 @@ services: environment: PORT: "7867" REDIS_URL: "redis://redis:6379" - DATABASE_URL: "postgres://{{ nextcloud_postgres_user | urlencode }}:{{ nextcloud_postgres_password | urlencode }}@db:5432/{{ nextcloud_postgres_db }}" + DATABASE_URL: "postgres://{{ nextcloud_postgres_user | urlencode | replace('/', '%2F') }}:{{ nextcloud_postgres_password | urlencode | replace('/', '%2F') }}@db:5432/{{ nextcloud_postgres_db }}" DATABASE_PREFIX: "oc_" NEXTCLOUD_URL: "http://nginx" networks: @@ -145,7 +145,7 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ nextcloud_traefik_network }} - - traefik.http.routers.{{ nextcloud_service_name }}-push.rule=Host(`{{ nextcloud_domains[0] }}`) && PathPrefix(`/push`) + - traefik.http.routers.{{ nextcloud_service_name }}-push.rule=({% for d in nextcloud_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor %}) && PathPrefix(`/push`) - traefik.http.services.{{ nextcloud_service_name }}-push.loadbalancer.server.port=7867 {% if nextcloud_use_ssl %} - traefik.http.routers.{{ nextcloud_service_name }}-push.entrypoints=websecure diff --git a/roles/opencloud/handlers/main.yml b/roles/opencloud/handlers/main.yml index 95b6986..7cbc094 100644 --- a/roles/opencloud/handlers/main.yml +++ b/roles/opencloud/handlers/main.yml @@ -5,4 +5,4 @@ - name: restart opencloud community.docker.docker_compose_v2: project_src: "{{ opencloud_docker_compose_dir }}" - state: restarted \ No newline at end of file + state: present \ No newline at end of file diff --git a/roles/traefik/defaults/main.yml b/roles/traefik/defaults/main.yml index 3e43412..eea7391 100644 --- a/roles/traefik/defaults/main.yml +++ b/roles/traefik/defaults/main.yml @@ -33,6 +33,18 @@ traefik_acme_tsig_secret: "" # TSIG secret traefik_acme_propagation_timeout: "120" traefik_acme_polling_interval: "2" traefik_acme_ttl: "60" +# Force lego's DNS lookups (SOA resolution, propagation checks) onto +# TCP instead of UDP. Useful when container egress can reach the +# nameserver on TCP/53 but UDP/53 is blocked or unreliable. Sets the +# upstream env var LEGO_EXPERIMENTAL_DNS_TCP_ONLY=true on the +# traefik container. +traefik_acme_tcp_only: false +# Disable lego's propagation check against the zone's authoritative +# nameservers. Use when the SOA-listed NS hostname resolves to an +# address that isn't reachable from this traefik host (e.g. a DMZ +# box that can only see the internal NS IP, not the public one). +# lego still polls via the configured `resolvers:` list. +traefik_acme_disable_ans_checks: false # Self-signed certificate configuration (for vagrant/testing) traefik_selfsigned_cert_dir: "{{ docker_volume_dir }}/certs" diff --git a/roles/traefik/handlers/main.yml b/roles/traefik/handlers/main.yml index 4abd95a..8af7aac 100644 --- a/roles/traefik/handlers/main.yml +++ b/roles/traefik/handlers/main.yml @@ -5,4 +5,4 @@ - name: restart traefik community.docker.docker_compose_v2: project_src: "{{ docker_compose_dir }}" - state: restarted + state: present diff --git a/roles/traefik/tasks/main.yml b/roles/traefik/tasks/main.yml index 159ba8f..8934037 100644 --- a/roles/traefik/tasks/main.yml +++ b/roles/traefik/tasks/main.yml @@ -9,7 +9,18 @@ - name: Build service registry from backend servers (DMZ mode) set_fact: - proxied_services: "{{ proxied_services | default([]) + hostvars[item].traefik_dmz_exposed_services | default([]) | map('combine', {'backend_host': hostvars[item].ansible_host | default(item)}) | list }}" + # Two-step merge so a service entry's own `backend_host` wins: + # entries that set it pass through unchanged, entries that don't + # get the backend host's ansible_host as fallback. The override + # lets a route target an internal FQDN covered by the backend + # cert's SANs instead of the raw IP (which would fail backend + # TLS verification at the proxy hop). + proxied_services: >- + {{ + proxied_services | default([]) + + (hostvars[item].traefik_dmz_exposed_services | default([]) | selectattr('backend_host', 'defined') | list) + + (hostvars[item].traefik_dmz_exposed_services | default([]) | rejectattr('backend_host', 'defined') | map('combine', {'backend_host': hostvars[item].ansible_host | default(item)}) | list) + }} loop: "{{ _backend_servers | default([]) }}" when: traefik_mode == 'dmz' diff --git a/roles/traefik/templates/docker-compose.yml.j2 b/roles/traefik/templates/docker-compose.yml.j2 index e45578b..6dbb9ec 100644 --- a/roles/traefik/templates/docker-compose.yml.j2 +++ b/roles/traefik/templates/docker-compose.yml.j2 @@ -12,6 +12,9 @@ services: RFC2136_PROPAGATION_TIMEOUT: "{{ traefik_acme_propagation_timeout }}" RFC2136_POLLING_INTERVAL: "{{ traefik_acme_polling_interval }}" RFC2136_TTL: "{{ traefik_acme_ttl }}" +{% if traefik_acme_tcp_only | default(false) %} + LEGO_EXPERIMENTAL_DNS_TCP_ONLY: "true" +{% endif %} {% endif %} ports: - "80:80" diff --git a/roles/traefik/templates/traefik.yml.j2 b/roles/traefik/templates/traefik.yml.j2 index 64bf351..1900bbb 100644 --- a/roles/traefik/templates/traefik.yml.j2 +++ b/roles/traefik/templates/traefik.yml.j2 @@ -48,6 +48,10 @@ certificatesResolvers: provider: rfc2136 resolvers: - "{{ traefik_acme_dns_nameserver }}" +{% if traefik_acme_disable_ans_checks | default(false) %} + propagation: + disableANSChecks: true +{% endif %} {% endif %} {% if traefik_use_ssl %} From 99d8968a2e4a6898e60de1f4704f10d2743bc734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:02:43 +0200 Subject: [PATCH 18/29] feat(traefik): configurable extra_hosts for container DNS overrides Add `traefik_extra_hosts` (list of `host:ip`) that maps straight into the traefik container's compose `extra_hosts`. Needed when a downstream middleware (e.g. ForwardAuth to authentik on a sibling LAN) has to resolve a public FQDN to an internal IP because the DMZ doesn't hairpin the public address back inside. Empty by default; behaviour unchanged for existing inventories. --- roles/traefik/defaults/main.yml | 7 +++++++ roles/traefik/templates/docker-compose.yml.j2 | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/roles/traefik/defaults/main.yml b/roles/traefik/defaults/main.yml index eea7391..ffc237e 100644 --- a/roles/traefik/defaults/main.yml +++ b/roles/traefik/defaults/main.yml @@ -11,6 +11,13 @@ service_name: traefik docker_compose_dir: "{{ docker_compose_base_dir }}/{{ service_name }}" docker_volume_dir: "{{ docker_volume_base_dir }}/{{ service_name }}" +# Optional /etc/hosts entries injected into the traefik container. Useful +# when downstream middlewares (e.g. ForwardAuth to an authentik instance +# running on a sibling LAN) need a public FQDN to resolve to an internal +# IP because the DMZ doesn't hairpin the public address back inside. +# Example: ["auth.example.com:172.16.19.101"] +traefik_extra_hosts: [] + # Deployment mode: 'dmz' or 'backend' # - dmz: Public-facing reverse proxy that routes to backend servers using file provider # - backend: Application server with docker provider for local container discovery diff --git a/roles/traefik/templates/docker-compose.yml.j2 b/roles/traefik/templates/docker-compose.yml.j2 index 6dbb9ec..9463e58 100644 --- a/roles/traefik/templates/docker-compose.yml.j2 +++ b/roles/traefik/templates/docker-compose.yml.j2 @@ -33,6 +33,12 @@ services: {% endif %} networks: - {{ traefik_network }} +{% if traefik_extra_hosts | default([]) | length > 0 %} + extra_hosts: +{% for h in traefik_extra_hosts %} + - "{{ h }}" +{% endfor %} +{% endif %} networks: {{ traefik_network }}: From 6411f94cce2f2b3f713bb51f5fdff4445d9cf336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:03:05 +0200 Subject: [PATCH 19/29] feat(authentik): split-horizon host rewrite + proxy-app mode/group bindings * `authentik_host_rewrite_domains`: extra hostnames that reach the authentik container but make it generate URLs (OIDC issuer, reset links) as if requested from the canonical `authentik_domains[0]`. Each entry gets its own traefik router and a URL-based loadbalancer service that disables passHostHeader and pins X-Forwarded-Host via middleware, so server-to-server calls on internal FQDNs keep traffic in the LAN while the iss claim stays aligned with the public host. Uses a network alias on the canonical FQDN so traefik (sharing the network) resolves the URL upstream to this very container. * proxy-app blueprint: - `mode` (default `forward_single`) lets callers pick between proxy, forward_single and forward_domain providers in one template. - `allowed_groups`: when set, emit one PolicyBinding per group on the application; authentik OR-evaluates bindings, so users in any listed group pass and others are denied. Existing inventories with an empty list see no behavioural change. --- roles/authentik/defaults/main.yml | 9 +++++ .../blueprints/blueprint-proxy-app.yaml.j2 | 27 +++++++++++++ .../authentik/templates/docker-compose.yml.j2 | 39 ++++++++++++++++++- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/roles/authentik/defaults/main.yml b/roles/authentik/defaults/main.yml index 3ff71be..880734e 100644 --- a/roles/authentik/defaults/main.yml +++ b/roles/authentik/defaults/main.yml @@ -17,6 +17,15 @@ authentik_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ authentik_service_ # server-to-server traffic so backend calls don't hairpin via DMZ. authentik_domains: - "authentik.local.test" + +# Hostnames that should reach authentik but make it generate URLs (OIDC +# issuer, password reset links, etc.) as if requested from the canonical +# `authentik_domains[0]` instead. Used for split-horizon setups where an +# internal FQDN (e.g. `auth.int.example.com`) keeps server-to-server +# traffic in the LAN but the iss claim must still match the public +# hostname that browsers see. Traefik handles each entry via a separate +# router that rewrites the Host header before forwarding to authentik. +authentik_host_rewrite_domains: [] authentik_image: "ghcr.io/goauthentik/server:2026.2.2" authentik_port: 9000 authentik_secret_key: "changeme-generate-a-random-string" diff --git a/roles/authentik/templates/blueprints/blueprint-proxy-app.yaml.j2 b/roles/authentik/templates/blueprints/blueprint-proxy-app.yaml.j2 index 5e29756..acfc7a9 100644 --- a/roles/authentik/templates/blueprints/blueprint-proxy-app.yaml.j2 +++ b/roles/authentik/templates/blueprints/blueprint-proxy-app.yaml.j2 @@ -20,6 +20,16 @@ entries: internal_host: "{{ item.internal_host }}" external_host: "{{ item.external_host }}" +{# Provider mode controls how authentik treats the proxy app: + - proxy : the outpost itself proxies traffic to internal_host + - forward_single : a single app behind an external reverse proxy + (traefik forwardauth talks to authentik per-domain) + - forward_domain : wildcard mode — one provider guards every host on a + cookie domain; configure forward_auth_mode=domain on + the outpost in that case. Default to forward_single + since that's the common ForwardAuth-with-traefik + pattern. #} + mode: {{ item.mode | default('forward_single') }} {% if item.skip_path_regex is defined and item.skip_path_regex|length > 0 %} skip_path_regex: | @@ -34,3 +44,20 @@ entries: name: "{{ item.name | default(item.slug) }}" slug: {{ item.slug }} provider: !KeyOf proxy-provider-{{ item.slug }} + +{% if item.allowed_groups is defined and item.allowed_groups | length > 0 %} +{# Restrict access to listed groups: one PolicyBinding per group, all bound + to the application. Authentik treats multiple bindings on the same target + as OR (a user matching any binding passes), and a request from a user in + none of the bound groups is denied. #} +{% for group_name in item.allowed_groups %} + - model: authentik_policies.policybinding + identifiers: + target: !KeyOf app-{{ item.slug }} + order: {{ loop.index0 }} + group: !Find [authentik_core.group, [name, "{{ group_name }}"]] + attrs: + enabled: true + negate: false +{% endfor %} +{% endif %} diff --git a/roles/authentik/templates/docker-compose.yml.j2 b/roles/authentik/templates/docker-compose.yml.j2 index f0193ec..e5b8a11 100644 --- a/roles/authentik/templates/docker-compose.yml.j2 +++ b/roles/authentik/templates/docker-compose.yml.j2 @@ -43,12 +43,19 @@ services: postgres: condition: service_healthy networks: - - {{ authentik_backend_network }} - - {{ authentik_traefik_network }} + {{ authentik_backend_network }}: {} + # Network alias so traefik (which shares this network) can resolve + # the canonical FQDN to this container directly. The URL-based + # service below uses that to send upstream traffic with a fixed + # Host header equal to the canonical hostname. + {{ authentik_traefik_network }}: + aliases: + - {{ authentik_domains[0] }} labels: - traefik.enable=true - traefik.docker.network={{ authentik_traefik_network }} - traefik.http.routers.{{ authentik_service_name }}.rule={% for d in authentik_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} + - traefik.http.routers.{{ authentik_service_name }}.service={{ authentik_service_name }} {% if authentik_use_ssl %} - traefik.http.routers.{{ authentik_service_name }}.entrypoints=websecure - traefik.http.routers.{{ authentik_service_name }}.tls=true @@ -59,6 +66,34 @@ services: - traefik.http.routers.{{ authentik_service_name }}.entrypoints=web {% endif %} - traefik.http.services.{{ authentik_service_name }}.loadbalancer.server.port={{ authentik_port }} +{% if authentik_host_rewrite_domains | length > 0 %} + # Server-to-server entry: a separate service points at this very + # container by the canonical FQDN (resolved via the network alias + # above) and disables passHostHeader so the upstream Host header + # becomes `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer + # URLs from X-Forwarded-Host (not Host), so we also pin that header + # via middleware. Together this keeps the iss claim aligned with + # the public hostname browsers see during login, even when the + # request itself arrived on an internal *.int.* FQDN. + - traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.server.url=http://{{ authentik_domains[0] }}:{{ authentik_port }} + - traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.passhostheader=false + - traefik.http.middlewares.{{ authentik_service_name }}-xfh-rewrite.headers.customrequestheaders.X-Forwarded-Host={{ authentik_domains[0] }} +{% for d in authentik_host_rewrite_domains %} + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.rule=Host(`{{ d }}`) + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.priority=100 + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.service={{ authentik_service_name }}-rewrite + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.middlewares={{ authentik_service_name }}-xfh-rewrite +{% if authentik_use_ssl %} + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.entrypoints=websecure + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} +{% else %} + - traefik.http.routers.{{ authentik_service_name }}-rewrite-{{ loop.index0 }}.entrypoints=web +{% endif %} +{% endfor %} +{% endif %} worker: image: {{ authentik_image }} From ce50bdb4d32702d4651867d31f5c68277457b7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:03:38 +0200 Subject: [PATCH 20/29] feat(drawio,garage): optional Authentik ForwardAuth in front of UIs Add `*_authentik_forward_auth` + `*_authentik_forward_auth_url` knobs to both roles. When enabled: * drawio: traefik attaches a ForwardAuth middleware pointing at the authentik embedded outpost; unauthenticated requests get redirected to log in and downstream sees X-Authentik-* identity headers. * garage WebUI: same ForwardAuth wiring, and `AUTH_USER_PASS` is dropped from the container env so authentik is the only gate. Tasks now key the htpasswd hash workflow off `_garage_webui_htpasswd_active` (`webui_enabled AND NOT authentik_forward_auth`); when authentik fronts the UI we skip hashing entirely. htpasswd hash is also now cached on disk and re-verified via `htpasswd -vbB` so unchanged passwords stop showing as `changed=true` on every run. Both knobs default to `false`, preserving existing htpasswd/plain behaviour. --- roles/drawio/defaults/main.yml | 9 ++- roles/drawio/templates/docker-compose.yml.j2 | 9 +++ roles/garage/defaults/main.yml | 12 +++- roles/garage/tasks/main.yml | 75 ++++++++++++++++++-- roles/garage/templates/docker-compose.yml.j2 | 12 ++++ 5 files changed, 110 insertions(+), 7 deletions(-) diff --git a/roles/drawio/defaults/main.yml b/roles/drawio/defaults/main.yml index 7b67976..2b2b758 100644 --- a/roles/drawio/defaults/main.yml +++ b/roles/drawio/defaults/main.yml @@ -17,4 +17,11 @@ drawio_extra_hosts: [] # Traefik configuration drawio_traefik_network: "proxy" -drawio_use_ssl: true \ No newline at end of file +drawio_use_ssl: true + +# Optional Authentik ForwardAuth (set to true and provide the URL to gate +# drawio behind an authentik proxy provider). Expects the authentik +# embedded outpost to expose the /outpost.goauthentik.io/auth/traefik +# endpoint on the configured URL (typically the public auth.* FQDN). +drawio_authentik_forward_auth: false +drawio_authentik_forward_auth_url: "" \ No newline at end of file diff --git a/roles/drawio/templates/docker-compose.yml.j2 b/roles/drawio/templates/docker-compose.yml.j2 index b6b9ef5..c9b0c9a 100644 --- a/roles/drawio/templates/docker-compose.yml.j2 +++ b/roles/drawio/templates/docker-compose.yml.j2 @@ -22,6 +22,15 @@ services: {% else %} - traefik.http.routers.{{ drawio_service_name }}.entrypoints=web {% endif %} +{% if drawio_authentik_forward_auth | default(false) %} + # ForwardAuth via the authentik embedded outpost. Unauthenticated + # requests get redirected to authentik to log in; authentik then + # sets X-Authentik-* headers traefik forwards downstream. + - traefik.http.middlewares.{{ drawio_service_name }}-authentik.forwardauth.address={{ drawio_authentik_forward_auth_url }} + - traefik.http.middlewares.{{ drawio_service_name }}-authentik.forwardauth.trustForwardHeader=true + - traefik.http.middlewares.{{ drawio_service_name }}-authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version + - traefik.http.routers.{{ drawio_service_name }}.middlewares={{ drawio_service_name }}-authentik +{% endif %} networks: {{ drawio_traefik_network }}: diff --git a/roles/garage/defaults/main.yml b/roles/garage/defaults/main.yml index 091e318..5a207eb 100644 --- a/roles/garage/defaults/main.yml +++ b/roles/garage/defaults/main.yml @@ -25,10 +25,20 @@ garage_webui_domain: "console.storage.local.test" garage_webui_enabled: true garage_webui_image: "khairul169/garage-webui:latest" garage_webui_port: 3909 -# WebUI basic auth credentials (plaintext, will be hashed automatically) +# WebUI basic auth credentials (plaintext, will be hashed automatically). +# Ignored when garage_webui_authentik_forward_auth is true — in that case +# authentik handles authentication via the ForwardAuth middleware below. garage_webui_username: "admin" garage_webui_password: "admin" +# Optional Authentik ForwardAuth in front of the WebUI. When true: +# - the AUTH_USER_PASS env-var is dropped from the container so htpasswd +# isn't enforced; authentik is the only gate. +# - traefik attaches a ForwardAuth middleware pointing at the URL below. +# Leave false to keep classic htpasswd protection. +garage_webui_authentik_forward_auth: false +garage_webui_authentik_forward_auth_url: "" + # Garage ports garage_s3_api_port: 3900 garage_s3_web_port: 3902 diff --git a/roles/garage/tasks/main.yml b/roles/garage/tasks/main.yml index 4aebbeb..4478f51 100644 --- a/roles/garage/tasks/main.yml +++ b/roles/garage/tasks/main.yml @@ -26,12 +26,77 @@ dest: "{{ garage_docker_compose_dir }}/garage.toml" mode: '0644' -- name: Generate bcrypt hash for webui password using htpasswd - ansible.builtin.shell: | - htpasswd -nbBC 10 "{{ garage_webui_username }}" "{{ garage_webui_password }}" - register: _garage_webui_password_hash +- name: Set webui htpasswd activation fact + ansible.builtin.set_fact: + # htpasswd only runs when the WebUI is enabled AND authentik ForwardAuth + # is not handling authentication. When authentik is in front, the + # compose template drops AUTH_USER_PASS so no hash is needed. + _garage_webui_htpasswd_active: >- + {{ + garage_webui_enabled + and not (garage_webui_authentik_forward_auth | default(false)) + }} + +- name: Read cached webui htpasswd hash + ansible.builtin.slurp: + src: "{{ garage_docker_compose_dir }}/webui.htpasswd" + register: _garage_webui_htpasswd_cached + failed_when: false changed_when: false - when: garage_webui_enabled + when: _garage_webui_htpasswd_active + +- name: Verify cached webui htpasswd hash still matches password + ansible.builtin.command: + argv: + - htpasswd + - -vbB + - "{{ garage_docker_compose_dir }}/webui.htpasswd" + - "{{ garage_webui_username }}" + - "{{ garage_webui_password }}" + register: _garage_webui_htpasswd_verify + failed_when: false + changed_when: false + no_log: true + when: + - _garage_webui_htpasswd_active + - _garage_webui_htpasswd_cached.content is defined + +- name: Generate bcrypt hash for webui password using htpasswd + ansible.builtin.command: + argv: + - htpasswd + - -nbBC + - "10" + - "{{ garage_webui_username }}" + - "{{ garage_webui_password }}" + register: _garage_webui_password_hash_new + changed_when: true + when: + - _garage_webui_htpasswd_active + - (_garage_webui_htpasswd_cached.content is not defined) + or (_garage_webui_htpasswd_verify.rc | default(1) != 0) + +- name: Persist webui htpasswd hash on disk + ansible.builtin.copy: + content: "{{ _garage_webui_password_hash_new.stdout }}\n" + dest: "{{ garage_docker_compose_dir }}/webui.htpasswd" + mode: '0600' + when: + - _garage_webui_htpasswd_active + - _garage_webui_password_hash_new is changed + +- name: Load current webui htpasswd hash + ansible.builtin.slurp: + src: "{{ garage_docker_compose_dir }}/webui.htpasswd" + register: _garage_webui_htpasswd_current + changed_when: false + when: _garage_webui_htpasswd_active + +- name: Expose current webui htpasswd hash to template + ansible.builtin.set_fact: + _garage_webui_password_hash: + stdout: "{{ (_garage_webui_htpasswd_current.content | b64decode).strip() }}" + when: _garage_webui_htpasswd_active - name: Create docker-compose file for garage template: diff --git a/roles/garage/templates/docker-compose.yml.j2 b/roles/garage/templates/docker-compose.yml.j2 index d344e5f..7b1c017 100644 --- a/roles/garage/templates/docker-compose.yml.j2 +++ b/roles/garage/templates/docker-compose.yml.j2 @@ -38,7 +38,9 @@ services: environment: API_BASE_URL: "http://{{ garage_service_name }}:{{ garage_admin_port }}" S3_ENDPOINT_URL: "http://{{ garage_service_name }}:{{ garage_s3_api_port }}" +{% if not (garage_webui_authentik_forward_auth | default(false)) %} AUTH_USER_PASS: '{{ _garage_webui_password_hash.stdout | replace("$", "$$") }}' +{% endif %} volumes: - {{ garage_docker_compose_dir }}/garage.toml:/etc/garage.toml:ro networks: @@ -60,6 +62,16 @@ services: - traefik.http.routers.{{ garage_service_name }}-console.service={{ garage_service_name }}-console - traefik.http.routers.{{ garage_service_name }}-console.priority=10 - traefik.http.services.{{ garage_service_name }}-console.loadbalancer.server.port={{ garage_webui_port }} +{% if garage_webui_authentik_forward_auth | default(false) %} + # ForwardAuth via the authentik embedded outpost. Unauthenticated + # requests are redirected to authentik; authentik then forwards + # X-Authentik-* identity headers downstream. htpasswd is disabled + # in the env block above so authentik is the only gate. + - traefik.http.middlewares.{{ garage_service_name }}-console-authentik.forwardauth.address={{ garage_webui_authentik_forward_auth_url }} + - traefik.http.middlewares.{{ garage_service_name }}-console-authentik.forwardauth.trustForwardHeader=true + - traefik.http.middlewares.{{ garage_service_name }}-console-authentik.forwardauth.authResponseHeaders=X-authentik-username,X-authentik-groups,X-authentik-entitlements,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version + - traefik.http.routers.{{ garage_service_name }}-console.middlewares={{ garage_service_name }}-console-authentik +{% endif %} {% endif %} networks: From 3855b3e0e77ee079be58e96a47f64abd0515caa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:03:58 +0200 Subject: [PATCH 21/29] fix(garage): make bootstrap & provision idempotent across reruns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bootstrap: `garage layout show` truncates node IDs to 16 chars, but the membership check compared against the full hex. After the first successful join, subsequent runs no longer found the short ID in `layout show` and re-issued `layout assign`, marking the task changed every time. Compare against both the truncated and the full form so a configured node stays detected. Also tag the read-only `garage node id` / `layout show` probes with `changed_when: false`. * provision keys: the old parser sliced `stdout_lines[1:]` to drop the header but missed that INFO log lines and ANSI escapes can interleave with table rows. Replace with an explicit `^GK[0-9a-fA-F]+` filter after stripping ANSI, so probe-output noise no longer corrupts the existing-keys set and triggers spurious `key new` calls. * provision buckets: same class of fix — match `^[0-9a-f]{16}\s` data rows instead of slicing `[2:]`, which broke when the table header wasn't exactly two lines. * provision permissions: pre-read `bucket info` for each (key, bucket) pair and only run `bucket allow` when the current `RWO` flag set for that key ID doesn't already match the desired permissions. Previously `bucket allow` ran unconditionally and reported changed every play. * `changed_when: false` on all read-only probes (`key list`, `key info`, `bucket list`). --- roles/garage/tasks/bootstrap.yml | 8 ++++- roles/garage/tasks/provision.yml | 53 +++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/roles/garage/tasks/bootstrap.yml b/roles/garage/tasks/bootstrap.yml index 6cab2cf..5dc7e6e 100644 --- a/roles/garage/tasks/bootstrap.yml +++ b/roles/garage/tasks/bootstrap.yml @@ -7,21 +7,27 @@ container: "{{ garage_service_name }}" command: /garage node id -q register: _garage_node_id + changed_when: false - name: Extract short node ID ansible.builtin.set_fact: _garage_node_id_short: "{{ _garage_node_id.stdout.split('@')[0] }}" +- name: Extract truncated node ID (first 16 chars, matches `garage layout show` output) + ansible.builtin.set_fact: + _garage_node_id_truncated: "{{ _garage_node_id_short[:16] }}" + - name: Check if node layout is configured community.docker.docker_container_exec: container: "{{ garage_service_name }}" command: /garage layout show register: _garage_layout_show failed_when: false + changed_when: false - name: Check if node is in layout ansible.builtin.set_fact: - _node_in_layout: "{{ _garage_node_id_short in _garage_layout_show.stdout }}" + _node_in_layout: "{{ (_garage_node_id_truncated in _garage_layout_show.stdout) or (_garage_node_id_short in _garage_layout_show.stdout) }}" - name: Configure garage node layout community.docker.docker_container_exec: diff --git a/roles/garage/tasks/provision.yml b/roles/garage/tasks/provision.yml index 1c2628e..dacf2c0 100644 --- a/roles/garage/tasks/provision.yml +++ b/roles/garage/tasks/provision.yml @@ -4,11 +4,17 @@ container: "{{ garage_service_name }}" command: /garage key list register: _existing_keys_output + changed_when: false when: garage_s3_keys | length > 0 - name: Parse existing key names ansible.builtin.set_fact: - _existing_keys: "{{ _existing_keys_output.stdout_lines[1:] | select('match', '^GK') | map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+)\\s+.*$', '\\1') | list }}" + # `garage key list` columns: ID Created Name Expiration. + # Data rows begin with a GK key ID; header is "ID Created ..." + # and INFO log lines may interleave on stderr (kept separate by + # docker_container_exec). Strip ANSI escapes defensively, filter to + # GK-prefixed rows, then take the 3rd whitespace-separated field. + _existing_keys: "{{ _existing_keys_output.stdout_lines | map('regex_replace', '\\x1b\\[[0-9;]*m', '') | select('match', '^GK[0-9a-fA-F]+') | map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+).*$', '\\1') | list }}" when: garage_s3_keys | length > 0 - name: Create S3 keys @@ -27,6 +33,7 @@ command: /garage key info {{ item.name }} loop: "{{ garage_s3_keys }}" register: _key_info_results + changed_when: false when: garage_s3_keys | length > 0 - name: Extract key IDs from info @@ -42,11 +49,21 @@ container: "{{ garage_service_name }}" command: /garage bucket list register: _existing_buckets_output + changed_when: false when: garage_s3_keys | length > 0 - name: Parse existing bucket names ansible.builtin.set_fact: - _existing_buckets: "{{ _existing_buckets_output.stdout_lines[2:] | map('split') | map('first') | list }}" + # `garage bucket list` columns: ID Created Global aliases Local aliases + # Data rows start with a hex bucket ID; filter to those and take the + # third whitespace-separated field (the global alias = bucket name). + _existing_buckets: >- + {{ + _existing_buckets_output.stdout_lines + | select('match', '^[0-9a-f]{16}\\s') + | map('regex_replace', '^\\S+\\s+\\S+\\s+(\\S+).*$', '\\1') + | list + }} when: garage_s3_keys | length > 0 - name: Get unique bucket names @@ -64,12 +81,37 @@ - item not in _existing_buckets failed_when: false +- name: Get current bucket permissions + community.docker.docker_container_exec: + container: "{{ garage_service_name }}" + command: /garage bucket info {{ item.1.name }} + loop: "{{ garage_s3_keys | subelements('buckets', skip_missing=True) }}" + loop_control: + label: "{{ item.1.name }}" + register: _bucket_info_results + changed_when: false + when: garage_s3_keys | length > 0 + - name: Set bucket permissions using key IDs community.docker.docker_container_exec: container: "{{ garage_service_name }}" - command: /garage bucket allow {{ item.1.name }} {% for perm in item.1.permissions %}--{{ perm }} {% endfor %}--key {{ _key_id_map[item.0.name] }} - loop: "{{ garage_s3_keys | subelements('buckets', skip_missing=True) }}" - when: garage_s3_keys | length > 0 + command: /garage bucket allow {{ item.item.1.name }} {% for perm in item.item.1.permissions %}--{{ perm }} {% endfor %}--key {{ _key_id_map[item.item.0.name] }} + loop: "{{ _bucket_info_results.results }}" + loop_control: + label: "{{ item.item.1.name }} -> {{ item.item.0.name }}" + when: + - garage_s3_keys | length > 0 + - >- + (item.stdout | regex_search( + '(?m)^\s*' ~ _wanted_flags ~ '\s+' ~ _key_id_map[item.item.0.name] + )) is none + vars: + _wanted_flags: >- + {{ + ('R' if 'read' in item.item.1.permissions else '-') + ~ ('W' if 'write' in item.item.1.permissions else '-') + ~ ('O' if 'owner' in item.item.1.permissions else '-') + }} # Export key credentials for use by other roles - name: Get detailed key information for all keys @@ -78,6 +120,7 @@ command: /garage key info {{ item.name }} --show-secret loop: "{{ garage_s3_keys }}" register: _key_details_results + changed_when: false when: garage_s3_keys | length > 0 - name: Build garage S3 credentials map From f0cd8ba43288dd5adccd93226d25c3ac5775400d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:04:17 +0200 Subject: [PATCH 22/29] fix(nextcloud): make occ-driven config tasks idempotent Every `occ config:app:set` / `ldap:set-config` / `notify_push:setup` call previously fired on every play, marking changed even when the stored value already matched. Now we read the current value first and only invoke the setter when it differs: * richdocuments (collabora): pre-read wopi_url, public_wopi_url, disable_certificate_verification, wopi_allowlist into a fact map; guard each `config:app:set` and tag `richdocuments:activate-config` with `changed_when: false` since it's a discovery refresh. * drawio: same pattern for DrawioUrl, DrawioTheme, DrawioOffline, comparing as strings (occ stores booleans as "1"/"0"). * user_ldap: pre-read `ldap:show-config s01 --output=json`, parse JSON defensively (occ logs interleave on stderr), and skip per-key `ldap:set-config` calls when the stored value already equals the desired one. * notify_push: skip `notify_push:setup` when the stored base_endpoint already matches the computed URL. * plugins: `app:install`/`app:enable` were treating "already installed/ enabled" output as a change. Add the negative match to `changed_when` so re-runs of a fully-provisioned site report ok rather than changed. --- roles/nextcloud/tasks/collabora.yml | 31 +++++++++++++++++++++++++-- roles/nextcloud/tasks/drawio.yml | 27 +++++++++++++++++++++-- roles/nextcloud/tasks/ldap.yml | 19 ++++++++++++++++ roles/nextcloud/tasks/notify_push.yml | 11 +++++++++- roles/nextcloud/tasks/plugins.yml | 8 +++++-- 5 files changed, 89 insertions(+), 7 deletions(-) diff --git a/roles/nextcloud/tasks/collabora.yml b/roles/nextcloud/tasks/collabora.yml index 2a7bd82..d9d4f62 100644 --- a/roles/nextcloud/tasks/collabora.yml +++ b/roles/nextcloud/tasks/collabora.yml @@ -1,28 +1,55 @@ #SPDX-License-Identifier: MIT-0 --- # tasks file for configuring Collabora in Nextcloud +- name: Read current richdocuments config values + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ config:app:get richdocuments {{ item }} + loop: + - wopi_url + - public_wopi_url + - disable_certificate_verification + - wopi_allowlist + register: _richdocuments_current + changed_when: false + failed_when: false + +- name: Build map of current richdocuments config + ansible.builtin.set_fact: + _richdocuments_cfg: "{{ _richdocuments_cfg | default({}) | combine({item.item: (item.stdout | default('')).strip()}) }}" + loop: "{{ _richdocuments_current.results }}" + loop_control: + label: "{{ item.item }}" + - name: Configure Collabora WOPI URL (server-to-server) community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" command: php /var/www/html/occ config:app:set richdocuments wopi_url --value=https://{{ nextcloud_collabora_domain }} + when: _richdocuments_cfg.wopi_url != ('https://' ~ nextcloud_collabora_domain) - name: Configure Collabora public WOPI URL (browser-facing) community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" command: php /var/www/html/occ config:app:set richdocuments public_wopi_url --value=https://{{ nextcloud_collabora_public_domain }} - when: nextcloud_collabora_public_domain is defined and nextcloud_collabora_public_domain != nextcloud_collabora_domain + when: + - nextcloud_collabora_public_domain is defined + - nextcloud_collabora_public_domain != nextcloud_collabora_domain + - _richdocuments_cfg.public_wopi_url != ('https://' ~ nextcloud_collabora_public_domain) - name: Configure certificate verification for Collabora community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" command: php /var/www/html/occ config:app:set richdocuments disable_certificate_verification --value={{ nextcloud_collabora_disable_cert_verification | ternary('yes', 'no') }} + when: _richdocuments_cfg.disable_certificate_verification != (nextcloud_collabora_disable_cert_verification | ternary('yes', 'no')) - name: Set Collabora WOPI allowlist community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" command: php /var/www/html/occ config:app:set richdocuments wopi_allowlist --value='' + when: _richdocuments_cfg.wopi_allowlist | default('') != '' - name: Activate richdocuments configuration (fetch discovery from Collabora) community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" - command: php /var/www/html/occ richdocuments:activate-config \ No newline at end of file + command: php /var/www/html/occ richdocuments:activate-config + changed_when: false \ No newline at end of file diff --git a/roles/nextcloud/tasks/drawio.yml b/roles/nextcloud/tasks/drawio.yml index bd2e17e..e693862 100644 --- a/roles/nextcloud/tasks/drawio.yml +++ b/roles/nextcloud/tasks/drawio.yml @@ -2,18 +2,41 @@ --- # tasks file for configuring draw.io in Nextcloud +- name: Read current drawio config values + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ config:app:get drawio {{ item }} + loop: + - DrawioUrl + - DrawioTheme + - DrawioOffline + register: _drawio_current + changed_when: false + failed_when: false + +- name: Build map of current drawio config + ansible.builtin.set_fact: + _drawio_cfg: "{{ _drawio_cfg | default({}) | combine({item.item: (item.stdout | default('')).strip()}) }}" + loop: "{{ _drawio_current.results }}" + loop_control: + label: "{{ item.item }}" + - name: Configure draw.io URL community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" command: php /var/www/html/occ config:app:set drawio DrawioUrl --value={{ nextcloud_drawio_url }} - when: nextcloud_drawio_url | length > 0 + when: + - nextcloud_drawio_url | length > 0 + - _drawio_cfg.DrawioUrl != nextcloud_drawio_url - name: Configure draw.io theme community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" command: php /var/www/html/occ config:app:set drawio DrawioTheme --value={{ nextcloud_drawio_theme }} + when: _drawio_cfg.DrawioTheme != (nextcloud_drawio_theme | string) - name: Configure draw.io offline mode community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" - command: php /var/www/html/occ config:app:set drawio DrawioOffline --value={{ nextcloud_drawio_offline }} \ No newline at end of file + command: php /var/www/html/occ config:app:set drawio DrawioOffline --value={{ nextcloud_drawio_offline }} + when: _drawio_cfg.DrawioOffline != (nextcloud_drawio_offline | string) \ No newline at end of file diff --git a/roles/nextcloud/tasks/ldap.yml b/roles/nextcloud/tasks/ldap.yml index dcb2392..89618d5 100644 --- a/roles/nextcloud/tasks/ldap.yml +++ b/roles/nextcloud/tasks/ldap.yml @@ -15,6 +15,24 @@ command: php /var/www/html/occ ldap:create-empty-config when: "'s01' not in ldap_show_config.stdout" +- name: Read current LDAP config for s01 + community.docker.docker_container_exec: + container: "{{ nextcloud_service_name }}-nextcloud-1" + command: php /var/www/html/occ ldap:show-config s01 --output=json + register: _ldap_show_s01 + changed_when: false + failed_when: false + +- name: Parse current LDAP config + ansible.builtin.set_fact: + _ldap_current: >- + {{ + (_ldap_show_s01.stdout | from_json) if ( + (_ldap_show_s01.stdout | default('') | trim) is match('^[\\[{]') + ) else {} + }} + when: _ldap_show_s01.rc | default(1) == 0 + - name: Configure LDAP settings community.docker.docker_container_exec: container: "{{ nextcloud_service_name }}-nextcloud-1" @@ -29,6 +47,7 @@ loop_control: label: "{{ item.key }}" no_log: true + when: ((_ldap_current | default({})).get(item.key) | default(none) | string) != (item.value | string) - name: Test LDAP configuration community.docker.docker_container_exec: diff --git a/roles/nextcloud/tasks/notify_push.yml b/roles/nextcloud/tasks/notify_push.yml index 1497c68..2fba4d9 100644 --- a/roles/nextcloud/tasks/notify_push.yml +++ b/roles/nextcloud/tasks/notify_push.yml @@ -2,7 +2,16 @@ --- # tasks file for configuring notify_push in Nextcloud +- name: Read current notify_push base endpoint + community.docker.docker_container_exec: + container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" + command: php /var/www/html/occ config:app:get notify_push base_endpoint + register: _notify_push_current + changed_when: false + failed_when: false + - name: Configure notify_push base endpoint community.docker.docker_container_exec: container: "{{ nextcloud_docker_compose_dir | basename }}-nextcloud-1" - command: php /var/www/html/occ notify_push:setup https://{{ nextcloud_notify_push_domain | default(nextcloud_domains[0]) }}/push \ No newline at end of file + command: php /var/www/html/occ notify_push:setup https://{{ nextcloud_notify_push_domain | default(nextcloud_domains[0]) }}/push + when: (_notify_push_current.stdout | default('') | trim) != ('https://' ~ (nextcloud_notify_push_domain | default(nextcloud_domains[0])) ~ '/push') \ No newline at end of file diff --git a/roles/nextcloud/tasks/plugins.yml b/roles/nextcloud/tasks/plugins.yml index 2a6d8a5..a93e37c 100644 --- a/roles/nextcloud/tasks/plugins.yml +++ b/roles/nextcloud/tasks/plugins.yml @@ -8,7 +8,9 @@ chdir: "{{ nextcloud_docker_compose_dir }}" loop: "{{ nextcloud_apps_to_install }}" register: app_install_result - changed_when: "'installed' in app_install_result.stdout" + changed_when: + - "'already installed' not in app_install_result.stdout" + - "'installed' in app_install_result.stdout" failed_when: - app_install_result.rc != 0 - "'already installed' not in app_install_result.stdout" @@ -19,7 +21,9 @@ chdir: "{{ nextcloud_docker_compose_dir }}" loop: "{{ nextcloud_apps_to_install }}" register: app_enable_result - changed_when: "'enabled' in app_enable_result.stdout" + changed_when: + - "'already enabled' not in app_enable_result.stdout" + - "'enabled' in app_enable_result.stdout" failed_when: - app_enable_result.rc != 0 - "'already enabled' not in app_enable_result.stdout" From 60464e6d239c4aeeafb8a6afb836ae611e41eb0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:04:33 +0200 Subject: [PATCH 23/29] fix(nextcloud): in-container patch for UserConfig::getValueBool TypeError nextcloud/server#59629: under PHP 8.x with OPcache, UserConfig::getValueBool() passes a non-string from getTypedValue() straight into strtolower(), throwing a TypeError on every authenticated request once user_ldap is involved. Fix landed in master (PR #59646) but no stable33 backport made it into 33.0.4. Discover all compose-managed nextcloud containers, check whether the `strtolower((string)` cast is already present, and `sed` it into `lib/private/Config/UserConfig.php` on the ones that still ship the broken version. Idempotent via grep guard so re-runs are no-ops. Remove this block once the deployed image >= 33.0.4 ships the upstream fix. --- roles/nextcloud/tasks/main.yml | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index e33088b..7ee804c 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -49,6 +49,42 @@ project_src: "{{ nextcloud_docker_compose_dir }}" state: present +# nextcloud/server#59629: UserConfig::getValueBool() passes a non-string from +# getTypedValue() into strtolower() under PHP 8.x + OPcache, throwing a +# TypeError on every authenticated request once user_ldap is involved. Fix +# is in master (PR #59646) but no stable33 backport landed before 33.0.4. +# Apply the (string) cast in-container; idempotent via grep guard. Remove +# this block once nextcloud_image >= 33.0.4. +- name: Discover nextcloud php containers needing the UserConfig patch + ansible.builtin.shell: + cmd: >- + docker ps --filter "label=com.docker.compose.project={{ nextcloud_docker_compose_dir | basename }}" + --filter "label=com.docker.compose.service=nextcloud" + --format '{% raw %}{{.Names}}{% endraw %}' + register: _nextcloud_php_containers + changed_when: false + +- name: Check UserConfig.php patch status per container + ansible.builtin.shell: + cmd: >- + docker exec {{ item }} grep -q "strtolower((string)" /var/www/html/lib/private/Config/UserConfig.php + loop: "{{ _nextcloud_php_containers.stdout_lines }}" + register: _nextcloud_userconfig_check + changed_when: false + failed_when: false + +- name: Apply UserConfig::getValueBool string-cast workaround + ansible.builtin.shell: + cmd: >- + docker exec {{ item.item }} + sed -i 's|$b = strtolower($this->getTypedValue|$b = strtolower((string)$this->getTypedValue|' + /var/www/html/lib/private/Config/UserConfig.php + loop: "{{ _nextcloud_userconfig_check.results }}" + loop_control: + label: "{{ item.item }}" + when: + - item.rc | default(1) != 0 + - name: Wait for Nextcloud to be ready ansible.builtin.shell: cmd: docker compose exec -T nextcloud php /var/www/html/occ status --output=json From a9c33baed904aaf103565b7be06cf33a7785043f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:16:23 +0200 Subject: [PATCH 24/29] feat(drawio): support extra hostnames via drawio_extra_domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add drawio_extra_domains (list, default empty). The traefik Host rule on the drawio router now expands to Host() || Host() ... so the same container can answer on additional FQDNs — e.g. an internal *.int.* name so a DMZ reverse-proxy can reach drawio via a backend hostname covered by the local traefik cert. Empty by default; behaviour unchanged for existing inventories. --- roles/drawio/defaults/main.yml | 4 ++++ roles/drawio/templates/docker-compose.yml.j2 | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/roles/drawio/defaults/main.yml b/roles/drawio/defaults/main.yml index 2b2b758..22b9238 100644 --- a/roles/drawio/defaults/main.yml +++ b/roles/drawio/defaults/main.yml @@ -11,6 +11,10 @@ drawio_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ drawio_service_name # Service configuration drawio_domain: "drawio.local.test" +# Additional hostnames the same drawio container should answer on +# (e.g. an internal *.int.* FQDN so a DMZ reverseproxy can reach +# drawio via a backend hostname covered by the local traefik cert). +drawio_extra_domains: [] drawio_image: "jgraph/drawio:latest" drawio_port: 8080 drawio_extra_hosts: [] diff --git a/roles/drawio/templates/docker-compose.yml.j2 b/roles/drawio/templates/docker-compose.yml.j2 index c9b0c9a..65eb396 100644 --- a/roles/drawio/templates/docker-compose.yml.j2 +++ b/roles/drawio/templates/docker-compose.yml.j2 @@ -14,7 +14,7 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ drawio_traefik_network }} - - traefik.http.routers.{{ drawio_service_name }}.rule=Host(`{{ drawio_domain }}`) + - traefik.http.routers.{{ drawio_service_name }}.rule={% set _all_domains = [drawio_domain] + (drawio_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} - traefik.http.services.{{ drawio_service_name }}.loadbalancer.server.port={{ drawio_port }} {% if drawio_use_ssl %} - traefik.http.routers.{{ drawio_service_name }}.entrypoints=websecure From 1dcff92240d545b0dfc79e47284fffeaa12d93ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 26 May 2026 14:16:47 +0200 Subject: [PATCH 25/29] docs(roles): add argument_specs and README for traefik, authentik, drawio, garage, nextcloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of the five roles touched in this branch now ships: * meta/argument_specs.yml: typed schema for every variable in defaults/main.yml plus the optional inputs surfaced via this branch (traefik_extra_hosts, authentik_host_rewrite_domains, authentik_proxy_apps.mode / .allowed_groups, drawio_extra_domains, drawio_authentik_forward_auth*, garage_webui_authentik_forward_auth*). All five specs load cleanly through ansible-core's ArgumentSpecValidator. * README.md: replaces the ansible-galaxy boilerplate (where it was still in place) with a focused write-up — service vars, required secrets, ForwardAuth/idempotency notes, dependencies, and a working example playbook. authentik and garage READMEs are rewritten to cover the new knobs while preserving their existing content. --- roles/authentik/README.md | 117 ++++++++++- roles/authentik/meta/argument_specs.yml | 193 ++++++++++++++++++ roles/drawio/README.md | 70 ++++--- roles/drawio/meta/argument_specs.yml | 64 ++++++ roles/garage/README.md | 179 ++++++++--------- roles/garage/meta/argument_specs.yml | 169 ++++++++++++++++ roles/nextcloud/README.md | 123 ++++++++++++ roles/nextcloud/meta/argument_specs.yml | 253 ++++++++++++++++++++++++ roles/traefik/README.md | 108 +++++++--- roles/traefik/meta/argument_specs.yml | 215 ++++++++++++++++++++ 10 files changed, 1348 insertions(+), 143 deletions(-) create mode 100644 roles/authentik/meta/argument_specs.yml create mode 100644 roles/drawio/meta/argument_specs.yml create mode 100644 roles/garage/meta/argument_specs.yml create mode 100644 roles/nextcloud/README.md create mode 100644 roles/nextcloud/meta/argument_specs.yml create mode 100644 roles/traefik/meta/argument_specs.yml diff --git a/roles/authentik/README.md b/roles/authentik/README.md index 8311190..b8e6345 100644 --- a/roles/authentik/README.md +++ b/roles/authentik/README.md @@ -1,28 +1,131 @@ # Authentik -Deploys Authentik identity provider with Docker Compose. +Deploys [authentik](https://goauthentik.io) (server + worker + Postgres) +as a Docker Compose stack behind Traefik, with all resources provisioned +via templated blueprints. + +## What this role does + +- Renders the Compose stack with traefik labels and an optional + split-horizon host rewrite (see below) +- Provisions local users, groups, OIDC apps, Proxy/ForwardAuth apps, + LDAP apps and outposts, and Entra ID OAuth sources via blueprints +- Configures the login screen (visible sources, local login fields) +- Supports declarative cleanup via `authentik_removed_*` lists ## Variables -See `defaults/main.yml` for all available variables. +Full spec with types and defaults: `meta/argument_specs.yml`. The most +common overrides: -## Blueprints +### Service + +- `authentik_domains` (required, list): FQDNs the router accepts. First + entry is the canonical hostname; further entries cover internal + `*.int.*` names for server-to-server traffic. +- `authentik_secret_key` (required): PG fernet / signing secret. + Generate with `openssl rand -base64 60`. +- `authentik_postgres_password` (required). +- `authentik_image`, `authentik_port`, `authentik_log_level`. + +### Split-horizon host rewrite + +`authentik_host_rewrite_domains` lists hostnames that should reach the +authentik container but make it generate URLs (OIDC issuer, password +reset links, etc.) as if the request had arrived on +`authentik_domains[0]`. + +For each entry the role: + +- Creates a dedicated traefik router on that hostname +- Routes it to a URL-based loadbalancer service that disables + `passHostHeader`, so the upstream Host header becomes the canonical + FQDN +- Pins `X-Forwarded-Host` via middleware so the iss claim stays aligned + with the public hostname browsers see + +Use case: an internal `auth.int.example.com` keeps server-to-server +traffic in the LAN, but Keycloak/Nextcloud/etc. still receive issuer +URLs matching `auth.example.com`. + +### Blueprints The role renders blueprints for: + - Local users (`authentik_local_users`) +- Groups (`authentik_groups`) - OIDC applications (`authentik_oidc_apps`) - Proxy applications (`authentik_proxy_apps`) - Proxy outposts (`authentik_proxy_outposts`) +- LDAP applications (`authentik_ldap_apps`) +- LDAP outpost (`authentik_ldap_outpost`) - Entra ID sources (`authentik_entra_sources`) -- Login screen sources (`authentik_login_source_ids`) +- Login-screen source visibility (`authentik_login_sources`) -Secrets are passed via `authentik_blueprint_env` using environment variable references. +Secrets are passed via the `authentik_blueprint_env` env-var indirection +so they never land in rendered blueprint YAML on disk. + +#### Proxy apps: mode and group restrictions + +Each entry in `authentik_proxy_apps` supports: + +- `mode` (default `forward_single`): one of `proxy`, `forward_single`, + `forward_domain` +- `allowed_groups`: when set, a `PolicyBinding` is emitted per group on + the application. authentik OR-evaluates bindings, so users in any + listed group pass and users in none are denied. + +Example: + +```yaml +authentik_proxy_apps: + - slug: drawio + name: drawio + external_host: "https://drawio.example.com" + mode: forward_single + allowed_groups: + - drawio-users + - admins +``` ## Removing resources -To remove resources from Authentik, move slugs to the removal lists: +Move slugs from the active list to the matching removal list: + - `authentik_removed_oidc_apps` - `authentik_removed_proxy_apps` - `authentik_removed_local_users` -After confirming deletion, remove the slug from the list. \ No newline at end of file +After authentik has applied the deletion blueprint, remove the slug +from the list to keep state clean. + +## Dependencies + +- Traefik network (`authentik_traefik_network`, default `proxy`) +- Internal backend network (`authentik_backend_network`, default `backend`) + +## Example playbook + +```yaml +- hosts: identity_servers + roles: + - role: digitalboard.core.authentik + vars: + authentik_domains: + - "auth.example.com" + - "auth.int.example.com" + authentik_host_rewrite_domains: + - "auth.int.example.com" + authentik_secret_key: "{{ vault_authentik_secret_key }}" + authentik_postgres_password: "{{ vault_authentik_pg_password }}" + authentik_proxy_apps: + - slug: drawio + name: drawio + external_host: "https://drawio.example.com" + mode: forward_single + allowed_groups: [drawio-users] +``` + +## License + +MIT-0 diff --git a/roles/authentik/meta/argument_specs.yml b/roles/authentik/meta/argument_specs.yml new file mode 100644 index 0000000..936778e --- /dev/null +++ b/roles/authentik/meta/argument_specs.yml @@ -0,0 +1,193 @@ +--- +argument_specs: + main: + short_description: Deploy authentik (server + worker + Postgres) via Docker Compose. + description: + - Renders a Compose stack for authentik with traefik labels, optional + TLS and a configurable split-horizon host-rewrite that keeps the OIDC + issuer URL on the canonical public hostname even when traffic enters + on an internal FQDN. + - Provisions resources through templated blueprints + (local users, groups, OIDC/Proxy/LDAP apps, outposts, OAuth sources). + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + authentik_service_name: + type: str + default: authentik + authentik_docker_compose_dir: + type: path + description: Defaults to C({{ docker_compose_base_dir }}/{{ authentik_service_name }}). + authentik_docker_volume_dir: + type: path + description: Defaults to C({{ docker_volume_base_dir }}/{{ authentik_service_name }}). + + authentik_domains: + type: list + elements: str + required: true + description: + - FQDNs the authentik router accepts. The first entry is the + canonical (public) hostname and is used for the network alias, + the X-Forwarded-Host rewrite target, and as the default OIDC + issuer. Further entries cover internal C(*.int.*) names used + for server-to-server traffic. + authentik_host_rewrite_domains: + type: list + elements: str + default: [] + description: + - Hostnames that should reach authentik but make it generate URLs + (OIDC issuer, password reset links, etc.) as if the request had + arrived on C(authentik_domains[0]). + - Each entry gets its own traefik router and a URL-based + loadbalancer service that disables passHostHeader and pins + X-Forwarded-Host via middleware. Used for split-horizon setups + where the LAN keeps server-to-server traffic but the iss claim + must match the public hostname browsers see. + authentik_image: + type: str + default: ghcr.io/goauthentik/server:2026.2.2 + authentik_port: + type: int + default: 9000 + authentik_secret_key: + type: str + required: true + description: PG fernet key / signing secret. Generate with C(openssl rand -base64 60). + + authentik_postgres_image: + type: str + default: postgres:16-alpine + authentik_postgres_db: + type: str + default: authentik + authentik_postgres_user: + type: str + default: authentik + authentik_postgres_password: + type: str + required: true + + authentik_traefik_network: + type: str + default: proxy + authentik_backend_network: + type: str + default: backend + authentik_use_ssl: + type: bool + default: true + + authentik_log_level: + type: str + choices: [trace, debug, info, warning, error] + default: info + authentik_error_reporting_enabled: + type: bool + default: false + + authentik_proxy_apps: + type: list + elements: dict + default: [] + description: + - Proxy/ForwardAuth applications rendered via the + C(blueprint-proxy-app.yaml.j2) template. + options: + slug: + type: str + required: true + name: + type: str + required: true + internal_host: + type: str + description: Required when C(mode=proxy). + external_host: + type: str + required: true + mode: + type: str + choices: [proxy, forward_single, forward_domain] + default: forward_single + description: + - "C(proxy): the outpost itself proxies traffic to internal_host." + - "C(forward_single): a single app behind an external reverse + proxy via ForwardAuth." + - "C(forward_domain): wildcard mode — one provider guards every + host on a cookie domain." + allowed_groups: + type: list + elements: str + description: + - If set, PolicyBindings are emitted (one per group, OR-evaluated). + Users in none of the listed groups are denied. + skip_path_regex: + type: str + flows: + type: dict + description: Authentication / authorization / invalidation flow slugs. + + authentik_proxy_outposts: + type: list + elements: dict + default: [] + + authentik_ldap_apps: + type: list + elements: dict + default: [] + authentik_ldap_outpost: + type: dict + default: {} + + authentik_oidc_apps: + type: list + elements: dict + default: [] + + authentik_entra_sources: + type: list + elements: dict + default: [] + authentik_login_sources: + type: list + elements: dict + default: [] + authentik_identification_stage_name: + type: str + default: default-authentication-identification + authentik_login_user_fields: + type: list + elements: str + choices: [username, email, upn] + default: [username, email] + description: Local login fields shown on the login screen. Empty list hides local login. + + authentik_groups: + type: list + elements: dict + default: [] + authentik_local_users: + type: list + elements: dict + default: [] + + authentik_removed_oidc_apps: + type: list + elements: str + default: [] + description: OIDC application slugs scheduled for deletion. + authentik_removed_proxy_apps: + type: list + elements: str + default: [] + authentik_removed_local_users: + type: list + elements: str + default: [] diff --git a/roles/drawio/README.md b/roles/drawio/README.md index 225dd44..ca8275a 100644 --- a/roles/drawio/README.md +++ b/roles/drawio/README.md @@ -1,38 +1,60 @@ -Role Name -========= +# Drawio -A brief description of the role goes here. +Ansible role to deploy [draw.io](https://www.drawio.com/) (the +self-hosted `jgraph/drawio` container) via Docker Compose behind +Traefik, with optional authentik ForwardAuth gating. -Requirements ------------- +## Requirements -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +- Docker and Docker Compose installed on the target host +- Ansible collection: `community.docker` +- Traefik with a shared `drawio_traefik_network` (default `proxy`) +- For ForwardAuth: a reachable authentik embedded outpost endpoint -Role Variables --------------- +## Role variables -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +Full spec with types and defaults: `meta/argument_specs.yml`. The most +common overrides: -Dependencies ------------- +### Service -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +- `drawio_domain`: canonical hostname used in the traefik Host rule + (default `drawio.local.test`). +- `drawio_extra_domains`: additional hostnames the same container + should answer on (e.g. an internal `*.int.*` FQDN so a DMZ proxy + can reach drawio via a backend hostname). +- `drawio_image`, `drawio_port`, `drawio_use_ssl`. -Example Playbook ----------------- +### Authentik ForwardAuth -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: +- `drawio_authentik_forward_auth`: set to `true` to gate the editor + behind authentik. +- `drawio_authentik_forward_auth_url`: full URL of the embedded + outpost ForwardAuth endpoint, e.g. + `https://auth.example.com/outpost.goauthentik.io/auth/traefik`. - - hosts: servers - roles: - - { role: username.rolename, x: 42 } +When enabled, traefik redirects unauthenticated requests to authentik +for login and forwards the resulting `X-Authentik-*` identity headers +downstream. -License -------- +## Dependencies -BSD +- Traefik network (`drawio_traefik_network`, default `proxy`) +- Optional: authentik with a Proxy/ForwardAuth provider for drawio + (see the `authentik` role's `authentik_proxy_apps`). -Author Information ------------------- +## Example playbook -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +```yaml +- hosts: app_servers + roles: + - role: digitalboard.core.drawio + vars: + drawio_domain: "drawio.example.com" + drawio_authentik_forward_auth: true + drawio_authentik_forward_auth_url: "https://auth.example.com/outpost.goauthentik.io/auth/traefik" +``` + +## License + +MIT-0 diff --git a/roles/drawio/meta/argument_specs.yml b/roles/drawio/meta/argument_specs.yml new file mode 100644 index 0000000..f5f1e41 --- /dev/null +++ b/roles/drawio/meta/argument_specs.yml @@ -0,0 +1,64 @@ +--- +argument_specs: + main: + short_description: Deploy draw.io diagram editor via Docker Compose behind Traefik. + description: + - Renders a Compose stack for jgraph/drawio with traefik labels, optional + TLS and optional authentik ForwardAuth gating. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + drawio_service_name: + type: str + default: drawio + drawio_docker_compose_dir: + type: path + description: Defaults to C({{ docker_compose_base_dir }}/{{ drawio_service_name }}). + + drawio_domain: + type: str + default: drawio.local.test + description: Canonical hostname used in the traefik Host rule. + drawio_extra_domains: + type: list + elements: str + default: [] + description: + - Additional hostnames the same drawio container should answer on, + e.g. an internal C(*.int.*) FQDN so a DMZ reverse-proxy can reach + drawio via a backend hostname covered by the local traefik cert. + drawio_image: + type: str + default: jgraph/drawio:latest + drawio_port: + type: int + default: 8080 + drawio_extra_hosts: + type: list + elements: str + default: [] + description: C(extra_hosts) entries injected into the container (Docker C(host:ip) syntax). + + drawio_traefik_network: + type: str + default: proxy + drawio_use_ssl: + type: bool + default: true + + drawio_authentik_forward_auth: + type: bool + default: false + description: + - When true, traefik attaches a ForwardAuth middleware pointing at + the authentik embedded outpost. Unauthenticated requests are + redirected to authentik for login and the resulting + C(X-Authentik-*) identity headers are forwarded downstream. + drawio_authentik_forward_auth_url: + type: str + default: '' + description: + - URL of the authentik ForwardAuth endpoint, typically + C(https://auth.example.com/outpost.goauthentik.io/auth/traefik). + Required when C(drawio_authentik_forward_auth=true). diff --git a/roles/garage/README.md b/roles/garage/README.md index a369527..4996eb8 100644 --- a/roles/garage/README.md +++ b/roles/garage/README.md @@ -1,113 +1,116 @@ -Garage -====== +# Garage -Ansible role to deploy Garage S3-compatible object storage using Docker Compose. +Ansible role to deploy [Garage](https://garagehq.deuxfleurs.fr/) S3-compatible +object storage via Docker Compose, with declarative key/bucket +provisioning and an optional WebUI behind htpasswd or authentik +ForwardAuth. -Requirements ------------- +## Requirements - Docker and Docker Compose installed on the target host - Ansible collection: `community.docker` -- Traefik reverse proxy (for external access) +- `htpasswd` (from `apache2-utils` / `httpd-tools`) when the WebUI is + enabled and authentik ForwardAuth is *not* used +- Traefik with a shared `garage_traefik_network` (default `proxy`) -Role Variables --------------- +## Role variables -Key variables defined in `defaults/main.yml`: +Full spec with types and defaults: `meta/argument_specs.yml`. The most +common overrides: -**Base Configuration:** -- `docker_compose_base_dir`: Base directory for Docker Compose files (default: `/etc/docker/compose`) -- `docker_volume_base_dir`: Base directory for Docker volumes (default: `/srv/data`) +### Service -**Garage Configuration:** -- `garage_service_name`: Service name (default: `garage`) -- `garage_image`: Garage Docker image (default: `dxflrs/garage:v2.1.0`) -- `garage_s3_domain`: Domain for S3 API endpoint (default: `storage.local.test`) -- `garage_web_domain`: Domain for S3 web endpoint (default: `web.storage.local.test`) -- `garage_webui_domain`: Domain for web console (default: `console.storage.local.test`) +- `garage_s3_domains`: FQDNs the S3 router accepts. First entry is the + canonical hostname and is used as `root_domain` in `garage.toml`. +- `garage_web_domain`, `garage_webui_domain`: separate hostnames for + the S3-website endpoint and the console. +- `garage_image`, `garage_replication_factor`, `garage_db_engine`, + `garage_s3_region`. -**Garage Storage Configuration:** -- `garage_replication_factor`: Replication factor (default: `1`) -- `garage_compression_level`: Compression level (default: `1`) -- `garage_db_engine`: Database engine (default: `lmdb`) -- `garage_s3_region`: S3 region (default: `us-east-1`) +### Required secrets -**Garage Ports:** -- `garage_s3_api_port`: S3 API port (default: `3900`) -- `garage_s3_web_port`: S3 web port (default: `3902`) -- `garage_admin_port`: Admin API port (default: `3903`) -- `garage_rpc_port`: RPC port (default: `3901`) +Generate with `openssl rand -hex 32` (32 bytes / 64 hex chars): -**Garage Security:** -- `garage_rpc_secret`: RPC secret for node communication -- `garage_admin_token`: Admin API token -- `garage_metrics_token`: Metrics API token +- `garage_rpc_secret`: node-to-node RPC secret +- `garage_admin_token`: admin API token +- `garage_metrics_token`: metrics endpoint token -**Garage WebUI Configuration:** -- `garage_webui_enabled`: Enable web UI (default: `true`) -- `garage_webui_image`: WebUI Docker image (default: `khairul169/garage-webui:latest`) -- `garage_webui_port`: WebUI port (default: `3909`) -- `garage_webui_username`: WebUI username (default: `admin`) -- `garage_webui_password`: WebUI password in plaintext (default: `admin`) +### WebUI authentication -**Traefik Configuration:** -- `garage_traefik_network`: Traefik network name (default: `proxy`) -- `garage_internal_network`: Internal network name (default: `internal`) -- `garage_use_ssl`: Enable SSL (default: `true`) +Three modes: -Dependencies ------------- +1. **htpasswd** (default): `garage_webui_username` / `garage_webui_password` + in plaintext. The role hashes the password with + `htpasswd -nbBC 10`, persists the hash on disk, and re-verifies with + `htpasswd -vbB` so unchanged passwords don't churn the play. +2. **authentik ForwardAuth**: set + `garage_webui_authentik_forward_auth: true` and + `garage_webui_authentik_forward_auth_url: + "https://auth.example.com/outpost.goauthentik.io/auth/traefik"`. + `AUTH_USER_PASS` is dropped from the container env so authentik is + the only gate. +3. **Disabled**: `garage_webui_enabled: false`. -This role requires: -- Traefik reverse proxy to be configured and the `proxy` network to be created -- `htpasswd` utility (from `apache2-utils` package) for generating bcrypt password hashes +### Layout bootstrap -Example Playbook ----------------- +Setting `garage_bootstrap_enabled: true` runs the bootstrap task, which +joins the local node to the layout (`zone: garage_bootstrap_zone`, +capacity: `garage_bootstrap_capacity`) on the first run. The check +tolerates the 16-char truncation that `garage layout show` performs. + +### Declarative S3 keys and buckets + +```yaml +garage_s3_keys: + - name: nextcloud + buckets: + - name: nextcloud-data + permissions: [read, write] + - name: backup + buckets: + - name: restic-prod + permissions: [read, write, owner] +``` + +The role: + +- Lists existing keys (`garage key list`), creates missing ones +- Lists existing buckets (`garage bucket list`), creates missing ones +- Reads current permissions via `garage bucket info` and runs + `garage bucket allow` only when the current RWO flags for the key + don't already match the desired permissions + +`stdout` parsing is hardened against ANSI escapes and interleaved INFO +log lines, so probe noise no longer produces spurious changes. + +## Dependencies + +- Traefik network (`garage_traefik_network`, default `proxy`) +- Internal network (`garage_internal_network`, default `internal`) + +## Example playbook ```yaml - hosts: storage_servers roles: - - role: garage + - role: digitalboard.core.garage vars: - garage_s3_domain: "storage.example.com" - garage_rpc_secret: "your-secure-rpc-secret" - garage_admin_token: "your-admin-token" - garage_webui_enabled: true - garage_webui_username: "admin" - garage_webui_password: "secure-password" + garage_s3_domains: + - "storage.example.com" + - "storage.int.example.com" + garage_rpc_secret: "{{ vault_garage_rpc_secret }}" + garage_admin_token: "{{ vault_garage_admin_token }}" + garage_metrics_token: "{{ vault_garage_metrics_token }}" + garage_bootstrap_enabled: true + garage_webui_authentik_forward_auth: true + garage_webui_authentik_forward_auth_url: "https://auth.example.com/outpost.goauthentik.io/auth/traefik" + garage_s3_keys: + - name: nextcloud + buckets: + - name: nextcloud-data + permissions: [read, write] ``` -**Note:** The WebUI password is specified in plaintext and will be automatically hashed using bcrypt during deployment. The role uses `htpasswd` to generate a secure bcrypt hash that is then properly escaped for use in Docker Compose. +## License -Post-Installation ------------------ - -After deployment, you need to configure the Garage cluster: - -1. Connect to the node and get the node ID: - ```bash - docker exec -ti garage /garage node id - ``` - -2. Configure the node layout: - ```bash - docker exec -ti garage /garage layout assign -z dc1 -c 1G - docker exec -ti garage /garage layout apply --version 1 - ``` - -3. Create a key for S3 access: - ```bash - docker exec -ti garage /garage key create my-key - ``` - -4. Create a bucket: - ```bash - docker exec -ti garage /garage bucket create my-bucket - docker exec -ti garage /garage bucket allow my-bucket --read --write --key my-key - ``` - -License -------- - -MIT-0 \ No newline at end of file +MIT-0 diff --git a/roles/garage/meta/argument_specs.yml b/roles/garage/meta/argument_specs.yml new file mode 100644 index 0000000..8441495 --- /dev/null +++ b/roles/garage/meta/argument_specs.yml @@ -0,0 +1,169 @@ +--- +argument_specs: + main: + short_description: Deploy Garage S3-compatible object storage via Docker Compose. + description: + - Renders a Compose stack for Garage with traefik labels, configures the + node layout on first run, and (optionally) provisions S3 keys, buckets + and per-key permissions declaratively. + - The optional WebUI can be protected by classic htpasswd or by + authentik ForwardAuth. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + garage_service_name: + type: str + default: garage + garage_docker_compose_dir: + type: path + description: Defaults to C({{ docker_compose_base_dir }}/{{ garage_service_name }}). + garage_docker_volume_dir: + type: path + description: Defaults to C({{ docker_volume_base_dir }}/{{ garage_service_name }}). + + garage_image: + type: str + default: dxflrs/garage:v2.1.0 + + garage_s3_domains: + type: list + elements: str + default: ['storage.local.test'] + description: + - FQDNs the garage S3 router accepts. The first entry is the + canonical domain and is used as the virtual-hosted-style + C(root_domain) in C(garage.toml). Further entries cover internal + C(*.int.*) names. + garage_web_domain: + type: str + default: web.storage.local.test + description: Hostname serving the S3-website endpoint. + garage_webui_domain: + type: str + default: console.storage.local.test + description: Hostname serving the WebUI console. + + garage_webui_enabled: + type: bool + default: true + garage_webui_image: + type: str + default: khairul169/garage-webui:latest + garage_webui_port: + type: int + default: 3909 + garage_webui_username: + type: str + default: admin + description: htpasswd username. Ignored when C(garage_webui_authentik_forward_auth=true). + garage_webui_password: + type: str + default: admin + description: + - Plaintext password; hashed with C(htpasswd -nbBC 10) and persisted + on disk so re-runs don't churn. Ignored when authentik ForwardAuth + is enabled. + garage_webui_authentik_forward_auth: + type: bool + default: false + description: + - When true the C(AUTH_USER_PASS) env-var is dropped from the WebUI + container and traefik attaches a ForwardAuth middleware pointing + at the URL below. authentik is then the only gate; htpasswd is + disabled. + garage_webui_authentik_forward_auth_url: + type: str + default: '' + description: + - Required when C(garage_webui_authentik_forward_auth=true). + Typically C(https://auth.example.com/outpost.goauthentik.io/auth/traefik). + + garage_s3_api_port: + type: int + default: 3900 + garage_s3_web_port: + type: int + default: 3902 + garage_admin_port: + type: int + default: 3903 + garage_rpc_port: + type: int + default: 3901 + + garage_replication_factor: + type: int + default: 1 + garage_compression_level: + type: int + default: 1 + garage_db_engine: + type: str + choices: [lmdb, sqlite, sled] + default: lmdb + garage_s3_region: + type: str + default: us-east-1 + garage_rpc_secret: + type: str + required: true + description: Hex secret for node-to-node RPC. Generate with C(openssl rand -hex 32). + garage_admin_token: + type: str + required: true + garage_metrics_token: + type: str + required: true + + garage_traefik_network: + type: str + default: proxy + garage_internal_network: + type: str + default: internal + garage_use_ssl: + type: bool + default: true + + garage_bootstrap_enabled: + type: bool + default: false + description: When true the bootstrap task ensures the node is in the layout. + garage_bootstrap_zone: + type: str + default: dc1 + description: Zone label assigned during layout bootstrap. + garage_bootstrap_capacity: + type: str + default: 1G + description: Capacity string passed to C(garage layout assign -c). + + garage_s3_keys: + type: list + elements: dict + default: [] + description: + - Declarative key + bucket + permission provisioning. The role + creates missing keys, missing buckets, and runs C(bucket allow) + only when the current RWO flags for a given key don't match. + options: + name: + type: str + required: true + buckets: + type: list + elements: dict + description: Buckets this key gets access to. + options: + name: + type: str + required: true + permissions: + type: list + elements: str + choices: [read, write, owner] + required: true diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md new file mode 100644 index 0000000..79214c4 --- /dev/null +++ b/roles/nextcloud/README.md @@ -0,0 +1,123 @@ +# Nextcloud + +Ansible role to deploy [Nextcloud](https://nextcloud.com/) (fpm) with +Postgres and Redis via Docker Compose, optional Collabora WOPI +integration, optional draw.io integration, optional notify_push +companion, optional S3 primary storage, plus OIDC and LDAP user +backends. + +## What this role does + +- Renders the Compose stack with traefik labels and TLS +- Installs and enables a configurable list of Nextcloud apps idempotently +- Configures Collabora (richdocuments), draw.io, OIDC providers and + LDAP via `occ` — every setting is read first and only written when + the stored value differs, so re-runs don't churn +- Sets up notify_push (when enabled) +- Applies an in-container PHP source workaround for the upstream + `UserConfig::getValueBool` TypeError on Nextcloud 33.0.3 (idempotent + via grep guard; remove the patch task once the deployed image + ships the upstream fix) + +## Requirements + +- Docker and Docker Compose installed on the target host +- Ansible collection: `community.docker` +- Traefik with a shared `nextcloud_traefik_network` (default `proxy`) + +## Role variables + +Full spec with types and defaults: `meta/argument_specs.yml`. The most +common overrides: + +### Service + +- `nextcloud_domains`: FQDNs the router accepts. First entry is the + canonical hostname (used for `OVERWRITEHOST` and notify_push setup). + Further entries cover internal `*.int.*` names so Collabora's WOPI + callback hits the instance on a name with a valid cert. +- `nextcloud_admin_password`, `nextcloud_postgres_password` (required). +- `nextcloud_memory_limit_mb`, `nextcloud_upload_limit_mb`. + +### Collabora + +- `nextcloud_enable_collabora`: toggle integration with a separately + deployed Collabora server (see the `collabora` role). +- `nextcloud_collabora_domain`: server-to-server hostname. +- `nextcloud_collabora_public_domain` (optional): browser-facing + hostname when split-horizon uses different names. + +### Draw.io + +- `nextcloud_enable_drawio`: enable the `integration_drawio` app. +- `nextcloud_drawio_url`: public draw.io URL. +- `nextcloud_drawio_theme`, `nextcloud_drawio_offline`. + +### Notify push + +- `nextcloud_enable_notify_push`: deploy the notify_push companion. +- `nextcloud_notify_push_domain` (optional): override the hostname + used by `occ notify_push:setup` to avoid hairpinning through the DMZ. + +### S3 primary storage + +Set `nextcloud_use_s3_storage: true` plus the `nextcloud_s3_*` block to +point Nextcloud at an external S3-compatible store (e.g. Garage, MinIO). + +### OIDC + +`nextcloud_oidc_providers` is a list of OIDC providers registered with +`user_oidc`. Required fields per entry: `identifier`, `display_name`, +`client_id`, `client_secret`, `discovery_url`. + +### LDAP + +Set `nextcloud_ldap_enabled: true` and provide `nextcloud_ldap_config` +as a dict of `occ ldap:set-config s01 KEY VALUE` pairs. The role reads +the current LDAP config via `occ ldap:show-config s01 --output=json` +and only calls `ldap:set-config` for keys whose stored value differs. + +## Dependencies + +- Traefik network (`nextcloud_traefik_network`, default `proxy`) +- Optional: `collabora`, `drawio`, `garage` roles for the corresponding + integrations +- Optional: an OIDC provider (Keycloak, authentik) reachable from + Nextcloud and a 389ds LDAP server when using `user_ldap` + +## Example playbook + +```yaml +- hosts: app_servers + roles: + - role: digitalboard.core.nextcloud + vars: + nextcloud_domains: + - "cloud.example.com" + - "cloud.int.example.com" + nextcloud_admin_password: "{{ vault_nextcloud_admin_password }}" + nextcloud_postgres_password: "{{ vault_nextcloud_pg_password }}" + + nextcloud_enable_collabora: true + nextcloud_collabora_domain: "office.int.example.com" + nextcloud_collabora_public_domain: "office.example.com" + + nextcloud_enable_notify_push: true + nextcloud_notify_push_domain: "cloud.int.example.com" + + nextcloud_oidc_providers: + - identifier: authentik + display_name: "Login with Authentik" + client_id: nextcloud + client_secret: "{{ vault_nextcloud_oidc_secret }}" + discovery_url: "https://auth.example.com/application/o/nextcloud/.well-known/openid-configuration" + mapping: + uid: preferred_username + display_name: name + email: email + groups: groups +``` + +## License + +MIT-0 diff --git a/roles/nextcloud/meta/argument_specs.yml b/roles/nextcloud/meta/argument_specs.yml new file mode 100644 index 0000000..b314889 --- /dev/null +++ b/roles/nextcloud/meta/argument_specs.yml @@ -0,0 +1,253 @@ +--- +argument_specs: + main: + short_description: Deploy Nextcloud (fpm) + Redis + Postgres via Docker Compose. + description: + - Renders a Compose stack for Nextcloud with traefik labels, optional + Collabora WOPI integration, optional draw.io integration, optional + notify_push companion, optional S3 primary storage, OIDC providers + and LDAP user backend. + - "All C(occ)-driven configuration tasks are idempotent: each setting + is read with C(config:app:get) (or C(ldap:show-config)) first and + only written when the stored value differs." + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + docker_volume_base_dir: + type: path + default: /srv/data + nextcloud_service_name: + type: str + default: nextcloud + nextcloud_docker_compose_dir: + type: path + nextcloud_docker_volume_dir: + type: path + + nextcloud_domains: + type: list + elements: str + default: ['nextcloud.local.test'] + description: + - FQDNs the nextcloud router accepts. The first entry is the + canonical domain (used for C(OVERWRITEHOST) and the + C(notify_push) setup). Further entries cover internal C(*.int.*) + names so Collabora's WOPI callback hits the instance on a name + with a valid certificate. + nextcloud_image: + type: str + default: nextcloud:fpm + nextcloud_redis_image: + type: str + default: redis:latest + nextcloud_port: + type: int + default: 80 + nextcloud_extra_hosts: + type: list + elements: str + default: [] + nextcloud_extra_networks: + type: list + elements: str + default: [] + nextcloud_allow_local_remote_servers: + type: bool + default: false + description: Allow requests to local network from Nextcloud (dev only). + + nextcloud_postgres_image: + type: str + default: postgres:15 + nextcloud_postgres_db: + type: str + default: nextcloud + nextcloud_postgres_user: + type: str + default: nextcloud + nextcloud_postgres_password: + type: str + required: true + + nextcloud_backend_network: + type: str + default: nextcloud-internal + nextcloud_traefik_network: + type: str + default: proxy + nextcloud_use_ssl: + type: bool + default: true + + nextcloud_enable_collabora: + type: bool + default: true + nextcloud_collabora_domain: + type: str + default: office.local.test + description: Hostname Nextcloud uses to talk to Collabora server-to-server. + nextcloud_collabora_public_domain: + type: str + description: + - Optional browser-facing hostname for Collabora; defaults to + C(nextcloud_collabora_domain) when unset. Set when split-horizon + uses different names for browser and server traffic. + nextcloud_collabora_disable_cert_verification: + type: bool + default: false + + nextcloud_enable_drawio: + type: bool + default: false + description: Enable the integration_drawio Nextcloud app and configure the URL/theme. + nextcloud_drawio_url: + type: str + default: '' + description: Public draw.io URL used by the integration_drawio app. + nextcloud_drawio_theme: + type: str + choices: [kennedy, atlas, dark, sketch, min] + default: kennedy + nextcloud_drawio_offline: + type: str + choices: ['yes', 'no'] + default: 'yes' + + nextcloud_use_s3_storage: + type: bool + default: false + description: Use S3 primary object storage instead of the local data dir. + nextcloud_s3_key: + type: str + default: changeme + nextcloud_s3_secret: + type: str + default: changeme + nextcloud_s3_region: + type: str + default: us-east-1 + nextcloud_s3_bucket: + type: str + default: nextcloud + nextcloud_s3_host: + type: str + default: s3.example.com + nextcloud_s3_port: + type: int + default: 443 + nextcloud_s3_ssl: + type: bool + default: true + nextcloud_s3_usepath_style: + type: bool + default: true + nextcloud_s3_autocreate: + type: bool + default: false + + nextcloud_admin_user: + type: str + default: admin + nextcloud_admin_password: + type: str + required: true + nextcloud_memory_limit_mb: + type: int + default: 1024 + nextcloud_upload_limit_mb: + type: int + default: 2048 + nextcloud_scale_factor: + type: int + default: 2 + + nextcloud_trusted_proxies: + type: str + default: '172.16.0.0/12' + description: Trusted proxy CIDR(s) — by default the Docker internal range. + + nextcloud_enable_notify_push: + type: bool + default: false + nextcloud_notify_push_image: + type: str + default: icewind1991/notify_push:1.3.1 + nextcloud_notify_push_domain: + type: str + description: + - Hostname used when calling C(occ notify_push:setup). Defaults to + the first C(nextcloud_domains) entry. Override with an internal + FQDN to avoid hairpinning the setup check through the DMZ; the + FQDN must also be in C(nextcloud_domains). + + nextcloud_apps_to_install: + type: list + elements: str + default: + - groupfolders + - richdocuments + - spreed + - user_ldap + - user_oidc + - whiteboard + - files_lock + - notify_push + description: + - Non-default Nextcloud apps to install + enable. + Install/enable detection is idempotent — re-runs report C(ok) + when the app is already present and enabled. + + nextcloud_oidc_allow_selfsigned: + type: bool + default: false + nextcloud_oidc_providers: + type: list + elements: dict + default: [] + description: OIDC providers registered with the user_oidc app. + options: + identifier: + type: str + required: true + display_name: + type: str + required: true + client_id: + type: str + required: true + client_secret: + type: str + required: true + discovery_url: + type: str + required: true + scope: + type: str + default: openid email profile + unique_uid: + type: bool + default: true + check_bearer: + type: bool + default: false + send_id_token_hint: + type: bool + default: true + mapping: + type: dict + nextcloud_oidc_providers_removed: + type: list + elements: str + default: [] + + nextcloud_ldap_enabled: + type: bool + default: false + nextcloud_ldap_config: + type: dict + default: {} + description: + - Key/value pairs passed to C(occ ldap:set-config s01 KEY VALUE). + The role reads the current config first and only invokes + C(set-config) when a stored value differs. diff --git a/roles/traefik/README.md b/roles/traefik/README.md index 225dd44..9266d18 100644 --- a/roles/traefik/README.md +++ b/roles/traefik/README.md @@ -1,38 +1,98 @@ -Role Name -========= +# Traefik -A brief description of the role goes here. +Ansible role to deploy Traefik v3 as a reverse proxy via Docker Compose, +either as a public-facing DMZ proxy (file provider) or as a backend +application proxy (docker provider). -Requirements ------------- +## Requirements -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +- Docker and Docker Compose installed on the target host +- Ansible collection: `community.docker` +- For ACME DNS-01: an RFC2136-capable nameserver with a delegated zone + for `_acme-challenge` records and a TSIG key -Role Variables --------------- +## Role variables -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +Full list with types and defaults: `meta/argument_specs.yml`. The most +common overrides: -Dependencies ------------- +### Deployment mode -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +- `traefik_mode`: `dmz` (file provider, routes to external backends) or + `backend` (docker provider, discovers local containers). Default `backend`. +- `traefik_backend_servers_to_proxy`: in `dmz` mode, restrict which + inventory hosts the DMZ aggregates services from. Empty = all members + of `backend_servers`. -Example Playbook ----------------- +### Networking -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: +- `traefik_network`: docker network connecting traefik to its containers + (default `proxy`). +- `traefik_extra_hosts`: list of `host:ip` entries injected as the + container's `extra_hosts`. Use when a downstream middleware + (e.g. ForwardAuth to authentik on a sibling LAN) must resolve a public + FQDN to an internal IP because the DMZ does not hairpin the public + address back inside. - - hosts: servers - roles: - - { role: username.rolename, x: 42 } +### Certificates -License -------- +- `traefik_cert_mode`: `acme` (Let's Encrypt via DNS-01) or `selfsigned` + (local wildcard). Default `selfsigned`. +- `traefik_acme_dns_zone`, `traefik_acme_dns_nameserver`, + `traefik_acme_tsig_key`, `traefik_acme_tsig_secret`: RFC2136 / TSIG + configuration for the ACME DNS-01 challenge. +- `traefik_acme_tcp_only`: force lego's DNS lookups onto TCP/53 when the + container cannot reach the nameserver over UDP. +- `traefik_acme_disable_ans_checks`: skip the authoritative-NS + propagation check when the SOA-listed NS resolves to an unreachable IP. -BSD +### Dashboard -Author Information ------------------- +- `traefik_enable_dashboard`: expose the traefik dashboard. +- `traefik_dashboard_domain`: when set, publish the dashboard on this + Host rule instead of the insecure port. -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +## Dependencies + +- Traefik network (`traefik_network`, default `proxy`) must be created + by the `base` role or by hand before this role runs. +- In `dmz` mode, the proxied backend services advertise themselves via + the `traefik_services` host_var on each backend host. + +## Example playbook + +Backend mode (one app server per host, docker provider): + +```yaml +- hosts: app_servers + roles: + - role: digitalboard.core.traefik + vars: + traefik_mode: backend + traefik_cert_mode: acme + traefik_ssl_email: ops@example.com + traefik_acme_dns_zone: "_acme.example.com." + traefik_acme_dns_nameserver: "10.0.0.53:53" + traefik_acme_tsig_key: "acme-key" + traefik_acme_tsig_secret: "{{ vault_traefik_tsig_secret }}" +``` + +DMZ mode (aggregates services from `backend_servers`): + +```yaml +- hosts: dmz_servers + roles: + - role: digitalboard.core.traefik + vars: + traefik_mode: dmz + traefik_cert_mode: acme + traefik_backend_servers_to_proxy: + - app01 + - app02 + traefik_extra_hosts: + - "auth.example.com:172.16.19.101" +``` + +## License + +MIT-0 diff --git a/roles/traefik/meta/argument_specs.yml b/roles/traefik/meta/argument_specs.yml new file mode 100644 index 0000000..3d0442a --- /dev/null +++ b/roles/traefik/meta/argument_specs.yml @@ -0,0 +1,215 @@ +--- +argument_specs: + main: + short_description: Deploy Traefik v3 as DMZ or backend reverse proxy via Docker Compose. + description: + - Renders a Docker Compose stack for Traefik with either the file provider + (DMZ mode, routes to external backends) or the docker provider (backend + mode, discovers local containers via labels). + - Supports ACME DNS-01 issuance (RFC2136 / TSIG) or a self-signed cert + bundle for local/Vagrant setups. + options: + docker_compose_base_dir: + type: path + default: /etc/docker/compose + description: Base directory under which the per-service compose dir is created. + docker_volume_base_dir: + type: path + default: /srv/data + description: Base directory under which the per-service volume dir is created. + service_name: + type: str + default: traefik + description: Compose project / service name; also used to build the per-service paths. + docker_compose_dir: + type: path + description: Compose project directory; defaults to C({{ docker_compose_base_dir }}/{{ service_name }}). + docker_volume_dir: + type: path + description: Per-service volume directory; defaults to C({{ docker_volume_base_dir }}/{{ service_name }}). + + traefik_extra_hosts: + type: list + elements: str + default: [] + description: + - Entries injected as C(extra_hosts) on the traefik container. + - Each entry has the Docker syntax C("host:ip"). + - Useful when a downstream middleware (e.g. ForwardAuth to authentik + on a sibling LAN) must resolve a public FQDN to an internal IP + because the DMZ does not hairpin the public address. + + traefik_mode: + type: str + choices: [dmz, backend] + default: backend + description: + - C(dmz) configures the file provider so the proxy forwards to + backend hosts (typically aggregated from the C(backend_servers) group). + - C(backend) configures the docker provider for local container discovery. + + traefik_use_ssl: + type: bool + default: true + description: Toggle TLS on the websecure entrypoint. + traefik_ssl_email: + type: str + default: admin@example.com + description: Contact e-mail used by the ACME resolver. + traefik_ssl_cert_resolver: + type: str + default: dns + description: Certificate resolver name referenced in router labels. + traefik_cert_mode: + type: str + choices: [acme, selfsigned] + default: selfsigned + description: C(acme) for Let's Encrypt via DNS-01, C(selfsigned) for a locally generated bundle. + + traefik_acme_dns_zone: + type: str + default: '' + description: Delegated zone used for the TSIG-signed updates (e.g. C(_acme.example.com.)). + traefik_acme_dns_nameserver: + type: str + default: '' + description: Nameserver lego talks to for the DNS challenge (C(host:port)). + traefik_acme_tsig_algorithm: + type: str + default: hmac-sha256 + description: TSIG algorithm. + traefik_acme_tsig_key: + type: str + default: '' + description: TSIG key name. + traefik_acme_tsig_secret: + type: str + default: '' + description: TSIG secret (base64). + traefik_acme_propagation_timeout: + type: str + default: '120' + description: lego DNS propagation timeout in seconds. + traefik_acme_polling_interval: + type: str + default: '2' + description: lego DNS propagation polling interval in seconds. + traefik_acme_ttl: + type: str + default: '60' + description: TTL applied to the C(_acme-challenge) TXT records. + traefik_acme_tcp_only: + type: bool + default: false + description: + - Sets C(LEGO_EXPERIMENTAL_DNS_TCP_ONLY=true) on the container so SOA + resolution and propagation checks use TCP/53. Use when UDP/53 is + blocked or unreliable on the container egress path. + traefik_acme_disable_ans_checks: + type: bool + default: false + description: + - Disable lego's propagation check against the zone's authoritative + nameservers (sets C(LEGO_DISABLE_CNAME_SUPPORT=) plus the + authoritative-NS-check skip). Use when the SOA-listed NS hostname + resolves to an address the proxy host cannot reach. + + traefik_selfsigned_cert_dir: + type: path + description: Output directory for the self-signed bundle. + traefik_selfsigned_cert_days: + type: int + default: 365 + description: Validity in days for the self-signed bundle. + traefik_selfsigned_common_name: + type: str + default: '*.local.test' + description: CN/SAN of the self-signed wildcard cert. + + traefik_enable_dashboard: + type: bool + default: false + description: Expose the traefik dashboard. + traefik_dashboard_domain: + type: str + default: '' + description: + - When non-empty, the dashboard is published on this Host rule instead + of the insecure port 8080. + + traefik_enable_access_logs: + type: bool + default: true + traefik_access_log_format: + type: str + choices: [common, json] + default: common + traefik_log_level: + type: str + choices: [DEBUG, INFO, WARN, ERROR, FATAL, PANIC] + default: INFO + + traefik_network: + type: str + default: proxy + description: Docker network connecting traefik to its routable containers. + + traefik_dmz_exposed_services: + type: list + elements: dict + default: [] + description: + - In C(dmz) mode, services collected from backend host_vars are + published via the file provider. Each entry needs C(name), + C(domain), C(port); C(protocol) and C(backend_host) are optional. + options: + name: + type: str + required: true + domain: + type: str + required: true + port: + type: int + required: true + protocol: + type: str + choices: [http, https] + default: http + backend_host: + type: str + description: Override the auto-selected backend host. + + traefik_services: + type: list + elements: dict + default: [] + description: + - Services defined directly on the DMZ proxy (not auto-discovered + from a backend host). Each entry must set C(backend_host). + options: + name: + type: str + required: true + domain: + type: str + required: true + backend_host: + type: str + required: true + port: + type: int + required: true + protocol: + type: str + choices: [http, https] + default: http + + traefik_backend_servers_to_proxy: + type: list + elements: str + default: [] + description: + - In C(dmz) mode, explicit list of backend hosts the DMZ proxy + should aggregate exposed services from. Empty means all members + of the C(backend_servers) inventory group. From 19864d79b205254daa788c959fd2b46c3d2c3c09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Wed, 27 May 2026 16:18:29 +0200 Subject: [PATCH 26/29] feat(services): multi-domain routing, split-horizon and OIDC hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundle of cross-role changes for the gymb services deployment: - Traefik routers: OR-combine opnform/homarr/bookstack Host rules with new *_extra_domains (internal *.int.* FQDNs for a DMZ reverseproxy), and emit tls.certresolver only when traefik_cert_mode == acme (drawio, homarr, opnform, send). - Split-horizon: bookstack_extra_hosts / opnform_extra_hosts add container /etc/hosts overrides so containers reach the IdP public FQDN over the LAN. - bookstack: assert the OIDC issuer resolves concretely (reject "//v2.0"), allowing non-Entra IdPs that override bookstack_oidc_issuer. - homarr: derive the bcrypt salt from the password digest so the admin hash is idempotent — no spurious template changes / container restarts. - opnform: PATCH an existing OIDC connection instead of skipping (applies corrected inventory on re-run); add OIDC_FORCE_LOGIN (enabled only after bootstrap) and an optional direct-SSO ingress entrypoint. Docs: READMEs and meta/argument_specs.yml updated for all new variables. --- roles/bookstack/README.md | 15 ++- roles/bookstack/defaults/main.yml | 8 ++ roles/bookstack/meta/argument_specs.yml | 18 ++++ roles/bookstack/tasks/main.yml | 8 +- .../bookstack/templates/docker-compose.yml.j2 | 8 +- roles/drawio/templates/docker-compose.yml.j2 | 3 + roles/homarr/README.md | 1 + roles/homarr/defaults/main.yml | 4 + roles/homarr/tasks/main.yml | 24 ++--- roles/homarr/templates/docker-compose.yml.j2 | 5 +- roles/opnform/README.md | 52 ++++++++- roles/opnform/defaults/main.yml | 26 +++++ roles/opnform/meta/argument_specs.yml | 45 ++++++++ roles/opnform/tasks/main.yml | 101 +++++++++++++++--- roles/opnform/templates/docker-compose.yml.j2 | 14 ++- roles/opnform/templates/nginx.conf.j2 | 11 ++ roles/send/templates/docker-compose.yml.j2 | 3 + 17 files changed, 309 insertions(+), 37 deletions(-) diff --git a/roles/bookstack/README.md b/roles/bookstack/README.md index 25fb789..6dfd776 100644 --- a/roles/bookstack/README.md +++ b/roles/bookstack/README.md @@ -22,9 +22,14 @@ The role asserts these are set; the play fails fast if any is empty: | `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) | +| `bookstack_oidc_client_id` | OIDC client ID (if OIDC on) | +| `bookstack_oidc_client_secret` | OIDC client secret (if OIDC on) | + +When OIDC is on, the role also asserts that `bookstack_oidc_issuer` +resolves to a concrete URL. For Entra ID this means setting +`bookstack_entra_tenant_id` (the default issuer interpolates it; an unset +tenant leaves `//v2.0` and fails the assert). For other IdPs (Authentik, +Keycloak) set `bookstack_oidc_issuer` directly instead. Provide via OpenBao lookup, Ansible Vault or `--extra-vars`. Never commit real secrets. @@ -34,6 +39,10 @@ real secrets. See `defaults/main.yml`. Frequently overridden: - `bookstack_domain`, `bookstack_base_url` +- `bookstack_extra_domains` (extra Host-rule hostnames, e.g. an internal + `*.int.*` FQDN for a DMZ reverseproxy) +- `bookstack_extra_hosts` (container `/etc/hosts` overrides for + split-horizon IdP access; entries as `host:ip`) - `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) diff --git a/roles/bookstack/defaults/main.yml b/roles/bookstack/defaults/main.yml index 3efbadb..ac464b8 100644 --- a/roles/bookstack/defaults/main.yml +++ b/roles/bookstack/defaults/main.yml @@ -16,6 +16,14 @@ bookstack_backup_dir: "{{ bookstack_docker_volume_dir }}/backup" # Service configuration bookstack_domain: "wiki.local.test" +# Additional hostnames the bookstack router answers on (e.g. an internal +# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered +# by the cert). +bookstack_extra_domains: [] +# Container-level /etc/hosts overrides — useful in split-horizon setups +# where the BookStack container needs to reach an IdP's public FQDN +# (used in the OIDC `iss` claim) over the LAN rather than via the DMZ. +bookstack_extra_hosts: [] bookstack_base_url: "https://{{ bookstack_domain }}" # Images — pin via inventory in production diff --git a/roles/bookstack/meta/argument_specs.yml b/roles/bookstack/meta/argument_specs.yml index 8546cde..07f0c06 100644 --- a/roles/bookstack/meta/argument_specs.yml +++ b/roles/bookstack/meta/argument_specs.yml @@ -37,6 +37,24 @@ argument_specs: type: str default: wiki.local.test description: Hostname used in the Traefik Host rule. + bookstack_extra_domains: + type: list + elements: str + default: [] + description: + - Additional hostnames the Traefik router answers on, OR-combined + with C(bookstack_domain). Useful for an internal C(*.int.*) FQDN + so a DMZ reverseproxy can reach a backend hostname covered by the + cert. + bookstack_extra_hosts: + type: list + elements: str + default: [] + description: + - Container-level C(/etc/hosts) overrides (Compose C(extra_hosts) + entries, C("host:ip")). Useful in split-horizon setups where the + BookStack container must reach an IdP's public FQDN (used in the + OIDC C(iss) claim) over the LAN rather than via the DMZ. bookstack_base_url: type: str description: Defaults to C("https://{{ bookstack_domain }}"). diff --git a/roles/bookstack/tasks/main.yml b/roles/bookstack/tasks/main.yml index 1ea325b..73218d2 100644 --- a/roles/bookstack/tasks/main.yml +++ b/roles/bookstack/tasks/main.yml @@ -14,7 +14,13 @@ - 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) + # Issuer URL must resolve to something concrete. The Entra default + # interpolates bookstack_entra_tenant_id; an unset tenant leaves + # "//v2.0" in the URL. Allow non-Entra IdPs (Authentik, Keycloak) + # that override bookstack_oidc_issuer directly. + - (not bookstack_oidc_enabled) or + (bookstack_oidc_issuer | length > 0 and + '//v2.0' not in bookstack_oidc_issuer) 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. diff --git a/roles/bookstack/templates/docker-compose.yml.j2 b/roles/bookstack/templates/docker-compose.yml.j2 index 863e316..3300826 100644 --- a/roles/bookstack/templates/docker-compose.yml.j2 +++ b/roles/bookstack/templates/docker-compose.yml.j2 @@ -45,13 +45,19 @@ services: networks: - {{ bookstack_traefik_network }} - internal +{% if bookstack_extra_hosts | length > 0 %} + extra_hosts: +{% for host in bookstack_extra_hosts %} + - "{{ host }}" +{% endfor %} +{% endif %} 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 }}.rule={% set _all_domains = [bookstack_domain] + (bookstack_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%}" - "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 }}" diff --git a/roles/drawio/templates/docker-compose.yml.j2 b/roles/drawio/templates/docker-compose.yml.j2 index 65eb396..a7e44b7 100644 --- a/roles/drawio/templates/docker-compose.yml.j2 +++ b/roles/drawio/templates/docker-compose.yml.j2 @@ -19,6 +19,9 @@ services: {% if drawio_use_ssl %} - traefik.http.routers.{{ drawio_service_name }}.entrypoints=websecure - traefik.http.routers.{{ drawio_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ drawio_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ drawio_service_name }}.entrypoints=web {% endif %} diff --git a/roles/homarr/README.md b/roles/homarr/README.md index 1e92cba..77d6447 100644 --- a/roles/homarr/README.md +++ b/roles/homarr/README.md @@ -46,6 +46,7 @@ See `defaults/main.yml` for the full list. Most useful overrides: | Variable | Default | Purpose | |---|---|---| | `homarr_domain` | `homarr.local.test` | Traefik Host rule | +| `homarr_extra_domains` | `[]` | Extra Host-rule hostnames (OR-combined), e.g. internal `*.int.*` FQDN | | `homarr_base_url` | `https://home.local.test` | NEXTAUTH_URL / BASE_URL | | `homarr_auth_providers` | `credentials` | `credentials`, `oidc`, or both | | `homarr_oidc_issuer` | empty | Identity provider issuer URL | diff --git a/roles/homarr/defaults/main.yml b/roles/homarr/defaults/main.yml index f6ef75e..3d22ee7 100644 --- a/roles/homarr/defaults/main.yml +++ b/roles/homarr/defaults/main.yml @@ -15,6 +15,10 @@ homarr_db: "{{ homarr_appdata_dir }}/db/db.sqlite" # Service configuration homarr_domain: "homarr.local.test" +# Additional hostnames the homarr router answers on (e.g. an internal +# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered +# by the cert). +homarr_extra_domains: [] homarr_image: "ghcr.io/homarr-labs/homarr:latest" homarr_port: 7575 homarr_use_docker: false diff --git a/roles/homarr/tasks/main.yml b/roles/homarr/tasks/main.yml index ffb0bb7..c7e4cea 100644 --- a/roles/homarr/tasks/main.yml +++ b/roles/homarr/tasks/main.yml @@ -112,19 +112,17 @@ # ===================================================================== - name: Generate bcrypt hash for admin password - ansible.builtin.shell: - cmd: python3 -c "import bcrypt, sys; print(bcrypt.hashpw(sys.stdin.read().encode(), bcrypt.gensalt(rounds=10)).decode())" - stdin: "{{ homarr_admin_password }}" - stdin_add_newline: false - delegate_to: localhost - become: false - register: bcrypt_result - changed_when: false - no_log: true - -- name: Set bcrypt hash fact ansible.builtin.set_fact: - homarr_bcrypt_hash: "{{ bcrypt_result.stdout }}" + # Deterministic salt derived from the password's SHA-256 digest so the + # hash stays stable across runs (idempotent — no spurious template + # changes / container restarts when the password is unchanged). The + # bcrypt salt alphabet is [./A-Za-z0-9]; the digest's hex chars are + # a strict subset, so we just take the first 22. + homarr_bcrypt_hash: >- + {{ homarr_admin_password + | password_hash('bcrypt', rounds=10, + salt=(homarr_admin_password + | hash('sha256'))[:22]) }} no_log: true # ===================================================================== @@ -161,4 +159,4 @@ register: seed_result changed_when: seed_result.rc == 0 when: admin_exists.stdout == "" - notify: restart homarr \ No newline at end of file + notify: restart homarr diff --git a/roles/homarr/templates/docker-compose.yml.j2 b/roles/homarr/templates/docker-compose.yml.j2 index 2d81063..5907763 100644 --- a/roles/homarr/templates/docker-compose.yml.j2 +++ b/roles/homarr/templates/docker-compose.yml.j2 @@ -29,10 +29,13 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ homarr_traefik_network }} - - traefik.http.routers.homarr.rule=Host(`{{ homarr_domain }}`) + - traefik.http.routers.homarr.rule={% set _all_domains = [homarr_domain] + (homarr_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} {% if homarr_use_ssl %} - traefik.http.routers.homarr.entrypoints=websecure - traefik.http.routers.homarr.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.homarr.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.homarr.entrypoints=web {% endif %} diff --git a/roles/opnform/README.md b/roles/opnform/README.md index 2dfad2d..0722178 100644 --- a/roles/opnform/README.md +++ b/roles/opnform/README.md @@ -11,9 +11,10 @@ Docker Compose stack behind Traefik. - 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) +## What this role does NOT do -- Does not pre-configure OIDC / identity_connections — set up via Admin UI +- Does not migrate existing OpnForm databases — only bootstraps fresh + installs (admin registration + OIDC connection are idempotent) ## Architecture note: why two reverse proxies? @@ -91,11 +92,14 @@ Leave `opnform_admin_email` / `opnform_admin_password` empty. Visit ## OIDC setup -Set `opnform_oidc_enabled: true` and the role creates an +Set `opnform_oidc_enabled: true` and the role provisions 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). +single OIDC connection per workspace, so the task is idempotent: it GETs +existing connections first, then either POSTs a new one or PATCHes the +existing one to the desired state. PATCHing (rather than skipping when +one exists) keeps inventory changes — e.g. a corrected issuer — applied +on re-runs instead of leaving stale values in the DB. **Prerequisite**: the admin bootstrap must be configured (`opnform_admin_email` + `opnform_admin_password`). The OIDC API @@ -138,6 +142,44 @@ opnform_oidc_admin_group: "opnform-admins" # mapped to role=admin Valid roles: `owner`, `admin`, `editor`, `member`. +### Force OIDC-only login + +```yaml +opnform_oidc_force_login: true # default false +``` + +Sets `OIDC_FORCE_LOGIN=true` on the API: password login is disabled and +every user must authenticate via OIDC. The role keeps force-login **off** +during the first deploy (the admin/OIDC bootstrap is password-based) and +switches it on only after the OIDC connection is provisioned, recreating +the API containers. Ensure all real users have addresses under +`opnform_oidc_domain` before enabling — there is no password fallback. + +### Direct-SSO entrypoint + +OpnForm has no native way to skip the email login form and jump straight +to the IdP. When enabled, the ingress serves a tiny redirect page that +calls `/api/auth/{slug}/redirect` (no domain check) and forwards the +browser to the IdP authorize URL. + +```yaml +opnform_oidc_sso_entrypoint: true # default false +opnform_oidc_sso_path: "/sso" # link users to https:///sso +``` + +## Networking / split-horizon + +```yaml +opnform_extra_domains: [] # extra Host-rule hostnames (OR-combined) +opnform_extra_hosts: [] # API container /etc/hosts overrides ("host:ip") +``` + +`opnform_extra_domains` adds internal `*.int.*` FQDNs so a DMZ +reverseproxy can reach a backend hostname covered by the cert. +`opnform_extra_hosts` lets the API containers reach the IdP's public FQDN +(used in the OIDC `iss` claim) over the LAN when the DMZ has no NAT +loopback. + ## Example playbook ```yaml diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml index 0f61c3a..9a79b07 100644 --- a/roles/opnform/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -16,6 +16,15 @@ opnform_redis_data_dir: "{{ opnform_docker_volume_dir }}/redis" # Service configuration opnform_domain: "forms.local.test" +# Additional hostnames the opnform router answers on (e.g. an internal +# *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered +# by the cert). +opnform_extra_domains: [] +# Container-level /etc/hosts overrides for the API containers — needed in +# split-horizon setups where the OpnForm API must reach the IdP's public +# FQDN (used in the OIDC discovery/iss claim) over the LAN rather than +# hairpinning through a DMZ that has no NAT loopback to its own public IP. +opnform_extra_hosts: [] opnform_base_url: "https://forms.local.test" # Images @@ -92,6 +101,12 @@ opnform_oidc_slug: "oidc" # with @example.com emails are redirected to the IdP). Required when # opnform_oidc_enabled is true. opnform_oidc_domain: "" +# When true, sets OIDC_FORCE_LOGIN on the api: password-based login is +# disabled entirely and every user must authenticate via OIDC. Only +# rendered when opnform_oidc_enabled is also true. Make sure all real +# users have addresses under opnform_oidc_domain before enabling — there +# is no password fallback once this is on. +opnform_oidc_force_login: false opnform_oidc_scopes: - openid - profile @@ -104,6 +119,17 @@ opnform_oidc_admin_group: "opnform-admins" # var. Each item: {idp_group: "", role: "owner|admin|editor|member"} opnform_oidc_group_role_mappings: [] +# Direct-SSO entrypoint. OpnForm has no built-in way to skip the email +# login form and jump straight to the IdP (verified: config/oidc.php only +# exposes force_login; the login form always routes by email domain). When +# this is enabled the ingress serves a tiny page at opnform_oidc_sso_path +# that calls OpnForm's /api/auth/{slug}/redirect endpoint (which performs +# no domain check) and forwards the browser to the returned authorize URL +# — nonce/state included. Link users to https:// instead +# of /login. Requires opnform_oidc_enabled. +opnform_oidc_sso_entrypoint: false +opnform_oidc_sso_path: "/sso" + # Traefik configuration opnform_traefik_network: "proxy" opnform_use_ssl: true diff --git a/roles/opnform/meta/argument_specs.yml b/roles/opnform/meta/argument_specs.yml index 9fbfc7a..5e1248d 100644 --- a/roles/opnform/meta/argument_specs.yml +++ b/roles/opnform/meta/argument_specs.yml @@ -38,6 +38,25 @@ argument_specs: type: str default: forms.local.test description: Hostname used in the traefik Host rule. + opnform_extra_domains: + type: list + elements: str + default: [] + description: + - Additional hostnames the Traefik router answers on, OR-combined + with C(opnform_domain). Useful for an internal C(*.int.*) FQDN so + a DMZ reverseproxy can reach a backend hostname covered by the + cert. + opnform_extra_hosts: + type: list + elements: str + default: [] + description: + - Container-level C(/etc/hosts) overrides for the API containers + (Compose C(extra_hosts) entries, C("host:ip")). Needed in + split-horizon setups where the OpnForm API must reach the IdP's + public FQDN (used in the OIDC discovery / C(iss) claim) over the + LAN rather than hairpinning through a DMZ with no NAT loopback. opnform_base_url: type: str default: https://forms.local.test @@ -184,6 +203,15 @@ argument_specs: description: - Email domain that triggers OIDC for matching users. Required when C(opnform_oidc_enabled=true). + opnform_oidc_force_login: + type: bool + default: false + description: + - "When true, sets C(OIDC_FORCE_LOGIN=true) on the api container: + password-based login is disabled and every user must authenticate + via OIDC. Only takes effect when C(opnform_oidc_enabled=true). + Ensure all real users have addresses under C(opnform_oidc_domain) + before enabling — there is no password fallback." opnform_oidc_scopes: type: list elements: str @@ -211,6 +239,23 @@ argument_specs: type: str required: true choices: [owner, admin, editor, member] + opnform_oidc_sso_entrypoint: + type: bool + default: false + description: + - When true (and C(opnform_oidc_enabled=true)) the nginx ingress + serves a small redirect page at C(opnform_oidc_sso_path) that + calls OpnForm's C(/api/auth/{slug}/redirect) endpoint and + forwards the browser to the returned IdP authorize URL. Lets + you link users straight to the IdP, skipping OpnForm's + email-based login form. OpnForm has no native option for this. + opnform_oidc_sso_path: + type: str + default: /sso + description: + - Path (on C(opnform_domain)) where the direct-SSO redirect page + is served when C(opnform_oidc_sso_entrypoint=true). Must start + with C(/) and not collide with OpnForm's own routes. opnform_traefik_network: type: str diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml index 68e093b..91901c5 100644 --- a/roles/opnform/tasks/main.yml +++ b/roles/opnform/tasks/main.yml @@ -76,6 +76,15 @@ mode: '0644' notify: restart opnform +# OIDC_FORCE_LOGIN disables OpnForm's password login — including the +# password-based admin/OIDC bootstrap this role performs below. So the +# first compose render always keeps force-login OFF; it is switched on +# only after the bootstrap completes (see step 7). This keeps a first +# deploy on a fresh host working even when opnform_oidc_force_login=true. +- name: Render compose with force-login disabled during bootstrap + ansible.builtin.set_fact: + _opnform_force_login_effective: false + - name: Deploy docker-compose file ansible.builtin.template: src: docker-compose.yml.j2 @@ -155,9 +164,12 @@ # ===================================================================== # 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. +# Provisions a single OIDC connection on the admin's default workspace. +# OpnForm enforces one OIDC connection per workspace, so we GET the +# existing connections first and then either POST a new one or PATCH the +# existing one to the desired state. PATCHing (rather than skipping when +# one exists) keeps inventory changes — e.g. a corrected issuer — applied +# on re-runs instead of leaving stale values in the DB forever. - name: Log in as admin to obtain OIDC API token ansible.builtin.uri: @@ -213,15 +225,12 @@ }} 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: +# Desired connection state shared by both the create (POST) and update +# (PATCH) calls below. client_secret is always sent: OpnForm's update +# endpoint only persists it when present, and on create it is required. +- name: Build desired OIDC connection body + ansible.builtin.set_fact: + _opnform_oidc_body: name: "{{ opnform_oidc_client_name }}" slug: "{{ opnform_oidc_slug }}" domain: "{{ opnform_oidc_domain }}" @@ -233,6 +242,18 @@ options: require_state: true group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}" + no_log: true + 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: "{{ _opnform_oidc_body }}" status_code: [201] validate_certs: false no_log: true @@ -240,6 +261,58 @@ - opnform_oidc_enabled | bool - opnform_existing_oidc.json | length == 0 +# An OIDC connection already exists: PATCH it to the desired state so +# inventory changes (e.g. a corrected issuer) are applied. OpnForm allows +# exactly one connection per workspace, so the first entry is ours. +- name: Update existing OIDC identity connection + ansible.builtin.uri: + url: >- + https://127.0.0.1/api/open/workspaces/{{ opnform_workspaces.json[0].id }}/oidc-connections/{{ opnform_existing_oidc.json[0].id }} + method: PATCH + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ opnform_oidc_token.json.token }}" + body_format: json + body: "{{ _opnform_oidc_body }}" + status_code: [200] + validate_certs: false + no_log: true + when: + - opnform_oidc_enabled | bool + - opnform_existing_oidc.json | length > 0 + +# ===================================================================== +# 7. ENABLE FORCE LOGIN (optional, must run last) +# ===================================================================== +# OIDC_FORCE_LOGIN disables password login — including the password-based +# admin/OIDC bootstrap above — so it is switched on only now, after the +# connection is provisioned. OpnForm itself only enforces force-login when +# an enabled OIDC connection exists, so the order matters: connection +# first, force-login second. +- name: Enable force login now that the OIDC connection exists + when: + - opnform_oidc_enabled | bool + - opnform_oidc_force_login | bool + block: + - name: Re-render compose with force-login enabled + ansible.builtin.set_fact: + _opnform_force_login_effective: true + + - name: Deploy docker-compose file with force-login enabled + ansible.builtin.template: + src: docker-compose.yml.j2 + dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + register: _opnform_force_login_compose + + - name: Apply force-login by recreating the api containers + community.docker.docker_compose_v2: + project_src: "{{ opnform_docker_compose_dir }}" + state: present + wait: true + wait_timeout: 180 + when: _opnform_force_login_compose is changed + - name: Display deployment info ansible.builtin.debug: msg: |- @@ -260,6 +333,10 @@ (slug: {{ opnform_oidc_slug }}, domain: {{ opnform_oidc_domain }}) Users with @{{ opnform_oidc_domain }} addresses will be redirected to {{ opnform_oidc_issuer }} on login. + {% if opnform_oidc_sso_entrypoint %} + Direct-SSO entrypoint: {{ opnform_base_url }}{{ opnform_oidc_sso_path }} + (link users here to skip the email login form) + {% endif %} {% 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 index de88a33..6b5866c 100644 --- a/roles/opnform/templates/docker-compose.yml.j2 +++ b/roles/opnform/templates/docker-compose.yml.j2 @@ -6,6 +6,12 @@ services: image: {{ opnform_api_image }} container_name: opnform-api restart: unless-stopped +{% if opnform_extra_hosts | length > 0 %} + extra_hosts: +{% for host in opnform_extra_hosts %} + - "{{ host }}" +{% endfor %} +{% endif %} volumes: - {{ opnform_storage_dir }}:/usr/share/nginx/html/storage:rw environment: &api-env @@ -14,6 +20,9 @@ services: APP_URL: "{{ opnform_base_url }}" APP_DEBUG: "false" SELF_HOSTED: "true" +{% if opnform_oidc_enabled and (_opnform_force_login_effective | default(false)) %} + OIDC_FORCE_LOGIN: "true" +{% endif %} LOG_CHANNEL: errorlog LOG_LEVEL: info @@ -173,10 +182,13 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ opnform_traefik_network }} - - traefik.http.routers.{{ opnform_service_name }}.rule=Host(`{{ opnform_domain }}`) + - traefik.http.routers.{{ opnform_service_name }}.rule={% set _all_domains = [opnform_domain] + (opnform_extra_domains | default([])) %}{% for d in _all_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} {% if opnform_use_ssl %} - traefik.http.routers.{{ opnform_service_name }}.entrypoints=websecure - traefik.http.routers.{{ opnform_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ opnform_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ opnform_service_name }}.entrypoints=web {% endif %} diff --git a/roles/opnform/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 index fa3193b..6f62840 100644 --- a/roles/opnform/templates/nginx.conf.j2 +++ b/roles/opnform/templates/nginx.conf.j2 @@ -15,6 +15,17 @@ server { index index.html index.htm index.php; +{% if opnform_oidc_enabled and opnform_oidc_sso_entrypoint %} + # Direct-SSO entrypoint: a tiny page that asks the API for the IdP + # authorize URL (no email/domain check on this endpoint) and forwards + # the browser there. Link users here instead of /login to skip the + # email field entirely. Exact-match so it wins over the `/` prefix. + location = {{ opnform_oidc_sso_path }} { + default_type text/html; + return 200 'Redirecting to sign-in…

Redirecting to sign-in…

'; + } + +{% endif %} location / { proxy_http_version 1.1; proxy_pass http://ui:3000; diff --git a/roles/send/templates/docker-compose.yml.j2 b/roles/send/templates/docker-compose.yml.j2 index 69a43ab..28f1eaa 100644 --- a/roles/send/templates/docker-compose.yml.j2 +++ b/roles/send/templates/docker-compose.yml.j2 @@ -50,6 +50,9 @@ services: {% if send_use_ssl %} - traefik.http.routers.{{ send_service_name }}.entrypoints=websecure - traefik.http.routers.{{ send_service_name }}.tls=true +{% if traefik_cert_mode | default('selfsigned') == 'acme' %} + - traefik.http.routers.{{ send_service_name }}.tls.certresolver={{ traefik_ssl_cert_resolver | default('dns') }} +{% endif %} {% else %} - traefik.http.routers.{{ send_service_name }}.entrypoints=web {% endif %} From 3236ca332fd241a63a9e4776df579f524be5f319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Wed, 27 May 2026 22:33:42 +0200 Subject: [PATCH 27/29] docs(collection): document all roles and fix metadata drift Replace ansible-galaxy init placeholders across the collection and correct documentation that drifted from the code, after a multi-agent review of every role README against its defaults, tasks and templates. Collection level: - README: role table for all 16 roles, requirements and role-ordering - galaxy.yml: declare community.docker and community.general deps, real description/tags/urls; normalize license to MIT-0 - meta/runtime.yml: requires_ansible '>=2.15.0' - plugins/README: document the homarr_layout filter and garage_credentials lookup instead of scaffold boilerplate Per-role meta/main.yml and README for the placeholder roles (389ds, authentik, authentik_outpost_ldap, base, collabora, drawio, garage, homarr, httpbin, keycloak, nextcloud, opencloud, traefik). Correctness fixes found during review: - keycloak: wrong domain default, drop invented keycloak_cert_resolver, document the provisioning feature - garage: root_domain is .s3., not the bare domain - opnform: jwt/front_api secrets use `openssl rand -hex 32`; align the validation fail_msg in tasks/main.yml accordingly - send: S3 example references garage_s3_domains[0] (was singular) - opencloud: document required opencloud_wopi_domain License normalized to MIT-0 across galaxy.yml, role meta and READMEs to match the SPDX headers. --- README.md | 69 +++++++++- galaxy.yml | 25 ++-- meta/runtime.yml | 5 +- plugins/README.md | 53 ++++---- roles/389ds/README.md | 63 +++++----- roles/389ds/meta/main.yml | 47 +++---- roles/authentik/README.md | 9 +- roles/authentik/meta/main.yml | 49 ++++---- roles/authentik_outpost_ldap/README.md | 62 ++++----- roles/authentik_outpost_ldap/meta/main.yml | 48 +++---- roles/base/README.md | 61 +++++---- roles/base/meta/main.yml | 46 +++---- roles/bookstack/README.md | 2 +- roles/bookstack/meta/main.yml | 2 +- roles/collabora/README.md | 64 +++++----- roles/collabora/meta/main.yml | 48 +++---- roles/drawio/meta/main.yml | 47 +++---- roles/garage/README.md | 6 +- roles/garage/defaults/main.yml | 5 +- roles/garage/meta/argument_specs.yml | 6 +- roles/garage/meta/main.yml | 48 +++---- roles/homarr/README.md | 18 ++- roles/homarr/meta/main.yml | 48 +++---- roles/httpbin/README.md | 54 ++++---- roles/httpbin/meta/main.yml | 48 +++---- roles/keycloak/README.md | 140 ++++++++++++++------- roles/keycloak/meta/main.yml | 48 +++---- roles/nextcloud/README.md | 7 +- roles/nextcloud/meta/main.yml | 28 +++++ roles/opencloud/README.md | 65 +++++----- roles/opencloud/meta/main.yml | 48 +++---- roles/opnform/README.md | 12 +- roles/opnform/tasks/main.yml | 7 +- roles/send/README.md | 8 +- roles/send/meta/main.yml | 2 +- roles/traefik/README.md | 13 +- roles/traefik/meta/argument_specs.yml | 9 +- roles/traefik/meta/main.yml | 45 +++---- 38 files changed, 740 insertions(+), 625 deletions(-) create mode 100644 roles/nextcloud/meta/main.yml diff --git a/README.md b/README.md index 5106324..f3c3168 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,68 @@ -# Ansible Collection - digitalboard.core +# Ansible Collection — digitalboard.core -Documentation for the collection. +This collection bundles the Ansible roles used to deploy the +[Digitalboard](https://git.digitalboard.ch/Digitalboard) platform: a set of +self-hosted, Docker-Compose-based services running behind Traefik, with +single sign-on provided by authentik or Keycloak. + +Each role provisions one service (or building block) as a self-contained +Docker Compose stack. Roles are consumed from the deployment repository +[reference-ansible](https://git.digitalboard.ch/Digitalboard/reference-ansible), +where inventories and playbooks tie the roles to concrete hosts. + +## Roles + +| Role | Description | +| --- | --- | +| `base` | Host baseline: Docker, apt packages and convenience tooling on Debian/Ubuntu. | +| `traefik` | Traefik v3 reverse proxy as a public DMZ proxy (file provider) or backend proxy (docker provider). | +| `authentik` | [authentik](https://goauthentik.io) IdP (server + worker + Postgres); resources via blueprints. | +| `authentik_outpost_ldap` | authentik LDAP outpost exposing an LDAP interface for apps that cannot speak OIDC. | +| `keycloak` | [Keycloak](https://www.keycloak.org/) IdP with a PostgreSQL backend. | +| `389ds` | [389 Directory Server](https://www.port389.org/) LDAP directory via Docker Compose. | +| `nextcloud` | [Nextcloud](https://nextcloud.com/) (fpm) + Postgres + Redis, optional Collabora/draw.io/notify_push. | +| `opencloud` | [OpenCloud](https://opencloud.eu/) file platform via Docker Compose. | +| `collabora` | [Collabora Online](https://www.collaboraonline.com/) (CODE), used as the WOPI backend for Nextcloud. | +| `bookstack` | [BookStack](https://www.bookstackapp.com/) wiki (LSIO + MariaDB) with OIDC SSO and daily backups. | +| `drawio` | [draw.io](https://www.drawio.com/) diagram editor, with optional authentik ForwardAuth gating. | +| `homarr` | [Homarr](https://github.com/homarr-labs/homarr) dashboard with seeded admin user and OIDC group. | +| `opnform` | [OpnForm](https://github.com/OpnForm/OpnForm) self-hosted form builder (api + ui + db + redis). | +| `send` | [Send](https://github.com/timvisee/send) (timvisee fork) file sharing with a Redis backend. | +| `garage` | [Garage](https://garagehq.deuxfleurs.fr/) S3-compatible object storage with key/bucket provisioning. | +| `httpbin` | [httpbin](https://httpbin.org/) HTTP request/response testing service for validating Traefik ingress. | + +## Usage + +Roles are not run from this repository directly. They are consumed from the +deployment repository +[reference-ansible](https://git.digitalboard.ch/Digitalboard/reference-ansible), +which holds the inventories, group/host variables and playbooks. See that +repository's `docs/` directory for getting-started instructions, how to run +Ansible and how secrets are managed. + +Per-role variables and their defaults are documented in each role's own +`README.md` and `meta/argument_specs.yml`. + +## Requirements + +- A Debian/Ubuntu target host (the `base` role bootstraps Docker there). +- ansible-core 2.15 or newer on the controller. +- The `community.docker` collection (used by nearly every role) and + `community.general` (used by the `keycloak` role). Both are declared as + `dependencies` in `galaxy.yml` and pulled in automatically when this + collection is installed via `ansible-galaxy`. + +The role READMEs use `community.hashi_vault` lookups in their examples to source +secrets from HashiCorp Vault. That is a documented convention, not a hard +dependency of the roles — supply the variables however you prefer. + +## Role ordering + +Within a play, apply the roles in dependency order: `base` first (Docker and the +host baseline), then `traefik` (the shared reverse proxy and its Docker network), +then the individual service roles (`authentik`, `keycloak`, `nextcloud`, …), +which attach to Traefik's network and expect Docker to be present. + +## License + +MIT-0. See individual roles for per-role license metadata. diff --git a/galaxy.yml b/galaxy.yml index c208d8f..3413a07 100644 --- a/galaxy.yml +++ b/galaxy.yml @@ -23,12 +23,12 @@ authors: ### OPTIONAL but strongly recommended # A short summary description of the collection -description: your collection description +description: Ansible roles to deploy the Digitalboard self-hosted service platform (Docker Compose + Traefik + SSO) # Either a single license or a list of licenses for content inside of a collection. Ansible Galaxy currently only # accepts L(SPDX,https://spdx.org/licenses/) licenses. This key is mutually exclusive with 'license_file' license: -- GPL-2.0-or-later +- MIT-0 # The path to the license file for the collection. This path is relative to the root of the collection. This key is # mutually exclusive with 'license' @@ -36,25 +36,36 @@ license_file: '' # A list of tags you want to associate with the collection for indexing/searching. A tag name has the same character # requirements as 'namespace' and 'name' -tags: [] +tags: + - digitalboard + - docker + - traefik + - sso + - selfhosted # Collections that this collection requires to be installed for it to be usable. The key of the dict is the # collection label 'namespace.name'. The value is a version range # L(specifiers,https://python-semanticversion.readthedocs.io/en/latest/#requirement-specification). Multiple version # range specifiers can be set and are separated by ',' -dependencies: {} +dependencies: + # Used by nearly every role: docker_compose_v2, docker_container, + # docker_container_exec, docker_network. Hard runtime dependency. + community.docker: '>=3.0.0' + # Used by the keycloak role (keycloak_realm/client/group/user and + # related modules) in roles/keycloak/tasks/provisioning.yml. + community.general: '>=7.0.0' # The URL of the originating SCM repository repository: https://git.digitalboard.ch/Digitalboard/digitalboard.core # The URL to any online docs -documentation: http://docs.example.com +documentation: https://git.digitalboard.ch/Digitalboard/digitalboard.core # The URL to the homepage of the collection/project -homepage: http://example.com +homepage: https://git.digitalboard.ch/Digitalboard/digitalboard.core # The URL to the collection issue tracker -issues: http://example.com/issue/tracker +issues: https://git.digitalboard.ch/Digitalboard/digitalboard.core/issues # A list of file glob-like patterns used to filter any files or directories that should not be included in the build # artifact. A pattern is matched from the relative path of the file or directory of the collection directory. This diff --git a/meta/runtime.yml b/meta/runtime.yml index 936cae9..aafe589 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -1,8 +1,9 @@ #SPDX-License-Identifier: MIT-0 --- # Collections must specify a minimum required ansible version to upload -# to galaxy -# requires_ansible: '>=2.9.10' +# to galaxy. Aligned with the highest min_ansible_version declared by the +# roles (the traefik role requires ansible-core 2.15). +requires_ansible: '>=2.15.0' # Content that Ansible needs to load from another location or that has # been deprecated/removed diff --git a/plugins/README.md b/plugins/README.md index 74076b6..ca7a63d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,31 +1,32 @@ -# Collections Plugins Directory +# Collection Plugins — digitalboard.core -This directory can be used to ship various plugins inside an Ansible collection. Each plugin is placed in a folder that -is named after the type of plugin it is in. It can also include the `module_utils` and `modules` directory that -would contain module utils and modules respectively. +This collection ships a small number of custom plugins that support the roles. +They are addressed by their fully qualified name, `digitalboard.core.`. -Here is an example directory of the majority of plugins currently supported by Ansible: +## Filter plugins (`filter/`) -``` -└── plugins - ├── action - ├── become - ├── cache - ├── callback - ├── cliconf - ├── connection - ├── filter - ├── httpapi - ├── inventory - ├── lookup - ├── module_utils - ├── modules - ├── netconf - ├── shell - ├── strategy - ├── terminal - ├── test - └── vars +`homarr_layout` — computes Homarr dashboard grid layouts (desktop / tablet / +mobile breakpoints) from a list of apps, returning a ready-to-render data +structure for the SQL seed. Used by the `homarr` role. + +```yaml +- name: Compute Homarr app layouts + ansible.builtin.set_fact: + homarr_layout: "{{ homarr_apps | digitalboard.core.homarr_compute_layouts }}" ``` -A full list of plugin types can be found at [Working With Plugins](https://docs.ansible.com/ansible-core/2.19/plugins/plugins.html). +## Lookup plugins (`lookup/`) + +`garage_credentials` — returns S3 credentials (`key_id`, `secret_key`) for a +named Garage key by executing a docker command on the target host. Used to wire +Garage object storage into consuming roles such as `nextcloud`. + +```yaml +nextcloud_s3_key: >- + {{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='backend')['key_id'] }} +nextcloud_s3_secret: >- + {{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='backend')['secret_key'] }} +``` + +No other plugin types (modules, action, callback, inventory, etc.) are currently +shipped by this collection. diff --git a/roles/389ds/README.md b/roles/389ds/README.md index 225dd44..65564f6 100644 --- a/roles/389ds/README.md +++ b/roles/389ds/README.md @@ -1,38 +1,43 @@ -Role Name -========= +# 389ds -A brief description of the role goes here. +Deploys [389 Directory Server](https://www.port389.org/) (`389ds/dirsrv`) +as an LDAP directory via Docker Compose. After the container starts, the +role creates the configured suffix and a set of base organizational +units (e.g. `users`, `groups`). -Requirements ------------- +## Requirements -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +- Docker and Docker Compose on the target host (e.g. via + `digitalboard.core.base`) +- Ansible collection: `community.docker` -Role Variables --------------- +## Role variables -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +| Variable | Default | Description | +| --- | --- | --- | +| `ds389_image` | `docker.io/389ds/dirsrv:3.1` | Container image. | +| `ds389_suffix` | `dc=example,dc=com` | Root suffix of the directory. | +| `ds389_root_dn` | `cn=Directory Manager` | Directory Manager bind DN. | +| `ds389_root_password` | `changeme` | Directory Manager password — **override this**. | +| `ds389_instance_name` | `localhost` | Directory server instance name (slapd config dir). | +| `ds389_hostname` | `389ds` | Container hostname (defaults to `ds389_service_name`). | +| `ds389_backend_network` | `backend` | Docker network LDAP clients connect over (created by Compose). | +| `ds389_ldap_port` | `3389` | Published LDAP port (container port 3389). | +| `ds389_ldaps_port` | `3636` | Published LDAPS port (container port 3636). | +| `ds389_base_ous` | `[users, groups]` | Base OUs created after startup. | -Dependencies ------------- +## Example -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +```yaml +- hosts: directory + become: true + roles: + - role: digitalboard.core.389ds + vars: + ds389_suffix: "dc=example,dc=org" + ds389_root_password: "{{ vault_ds389_root_password }}" +``` -Example Playbook ----------------- +## License -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: - - - hosts: servers - roles: - - { role: username.rolename, x: 42 } - -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/389ds/meta/main.yml b/roles/389ds/meta/main.yml index 6f91fd3..37925db 100644 --- a/roles/389ds/meta/main.yml +++ b/roles/389ds/meta/main.yml @@ -1,35 +1,26 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy 389 Directory Server (LDAP) via Docker Compose + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - 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: + - 389ds + - ldap + - directory + - docker + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/authentik/README.md b/roles/authentik/README.md index b8e6345..2b909df 100644 --- a/roles/authentik/README.md +++ b/roles/authentik/README.md @@ -101,8 +101,13 @@ from the list to keep state clean. ## Dependencies -- Traefik network (`authentik_traefik_network`, default `proxy`) -- Internal backend network (`authentik_backend_network`, default `backend`) +- Run `digitalboard.core.base` first (Docker) and have the `community.docker` + collection installed; the role drives the stack via + `community.docker.docker_compose_v2`. +- Traefik network (`authentik_traefik_network`, default `proxy`) must exist + beforehand (e.g. created by the traefik role); it is referenced as an + external network in the Compose file. +- Internal backend network (`authentik_backend_network`, default `backend`). ## Example playbook diff --git a/roles/authentik/meta/main.yml b/roles/authentik/meta/main.yml index 6f91fd3..b8aef66 100644 --- a/roles/authentik/meta/main.yml +++ b/roles/authentik/meta/main.yml @@ -1,35 +1,28 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy authentik (server + worker + Postgres) via Docker Compose with blueprint-provisioned resources + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - 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: + - authentik + - oidc + - sso + - idp + - docker + - traefik + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/authentik_outpost_ldap/README.md b/roles/authentik_outpost_ldap/README.md index 225dd44..430a4a0 100644 --- a/roles/authentik_outpost_ldap/README.md +++ b/roles/authentik_outpost_ldap/README.md @@ -1,38 +1,44 @@ -Role Name -========= +# authentik_outpost_ldap -A brief description of the role goes here. +Deploys an [authentik](https://goauthentik.io) LDAP outpost via Docker +Compose. The outpost exposes an LDAP interface backed by authentik, so +applications that cannot speak OIDC (e.g. Nextcloud or OpenCloud LDAP +backends) can still authenticate against the central IdP. -Requirements ------------- +The outpost connects back to an authentik server using an outpost token +issued in the authentik admin interface. The image version must match +the authentik server version. -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +## Requirements -Role Variables --------------- +- Docker and Docker Compose on the target host (e.g. via + `digitalboard.core.base`) +- Ansible collection: `community.docker` -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +## Role variables -Dependencies ------------- +| Variable | Default | Description | +| --- | --- | --- | +| `authentik_outpost_ldap_image` | `ghcr.io/goauthentik/ldap:2026.2.2` | Outpost image (match the server version). | +| `authentik_outpost_ldap_host` | `https://authentik.local.test` | URL of the authentik server. | +| `authentik_outpost_ldap_token` | `changeme` | Outpost token — **override this**. | +| `authentik_outpost_ldap_insecure` | `"true"` | Skip TLS verification toward the authentik server. | +| `authentik_outpost_ldap_network` | `ldap` | Docker network LDAP clients connect over (created by the role). | +| `authentik_outpost_ldap_authentik_network` | _unset_ | Optional extra external network to the authentik server. | +| `authentik_outpost_ldap_extra_hosts` | `[]` | Extra `host:ip` entries for in-container DNS. | -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +## Example -Example Playbook ----------------- +```yaml +- hosts: directory + become: true + roles: + - role: digitalboard.core.authentik_outpost_ldap + vars: + authentik_outpost_ldap_host: "https://auth.example.com" + authentik_outpost_ldap_token: "{{ vault_authentik_ldap_outpost_token }}" +``` -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: +## License - - hosts: servers - roles: - - { role: username.rolename, x: 42 } - -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/authentik_outpost_ldap/meta/main.yml b/roles/authentik_outpost_ldap/meta/main.yml index 6f91fd3..5f2a051 100644 --- a/roles/authentik_outpost_ldap/meta/main.yml +++ b/roles/authentik_outpost_ldap/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy an authentik LDAP outpost via Docker Compose for applications that cannot use OIDC + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - 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: + - authentik + - ldap + - outpost + - sso + - docker + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/base/README.md b/roles/base/README.md index 225dd44..4d67213 100644 --- a/roles/base/README.md +++ b/roles/base/README.md @@ -1,38 +1,45 @@ -Role Name -========= +# base -A brief description of the role goes here. +Host baseline for the Digitalboard platform. Installs Docker (engine, +CLI, containerd, buildx, compose plugin) and a small set of apt and +convenience packages on Debian/Ubuntu, and sets the shared directory +layout every other role builds on. -Requirements ------------- +This role is intended to run first on every host, before any +service role. -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +## What it does -Role Variables --------------- +- Installs Docker prerequisites (`apt-transport-https`, `ca-certificates`, + `curl`, `gnupg`, `lsb-release`, `apache2-utils` for `htpasswd`) plus + convenience packages (`htop`, `ncdu`, `vim`) and Docker itself + (`docker-ce`, `docker-ce-cli`, `containerd.io`, `docker-buildx-plugin`, + `docker-compose-plugin`). +- Optionally configures Docker registry mirrors via `/etc/docker/daemon.json`. +- Starts and enables the Docker service and writes a custom `/etc/motd`. -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +This role defines the shared directory-layout variables +(`docker_compose_base_dir`, `docker_volume_base_dir`) that every service +role consumes, but the per-service subdirectories are created by the +respective service roles, not here. -Dependencies ------------- +## Role variables -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +| Variable | Default | Description | +| --- | --- | --- | +| `docker_compose_base_dir` | `/etc/docker/compose` | Root directory for per-service Compose projects. | +| `docker_volume_base_dir` | `/srv/data` | Root directory for per-service persistent volumes. | +| `docker_registry_mirrors` | `[]` | Optional list of registry mirror URLs; empty disables mirrors. | -Example Playbook ----------------- +## Example -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: +```yaml +- hosts: all + become: true + roles: + - digitalboard.core.base +``` - - hosts: servers - roles: - - { role: username.rolename, x: 42 } +## License -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/base/meta/main.yml b/roles/base/meta/main.yml index 36b9858..4e2a015 100644 --- a/roles/base/meta/main.yml +++ b/roles/base/meta/main.yml @@ -1,35 +1,25 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Host baseline — install Docker, required apt packages and convenience tooling on Debian/Ubuntu + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.1 - - # 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: + - base + - docker + - bootstrap + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/bookstack/README.md b/roles/bookstack/README.md index 6dfd776..83e0164 100644 --- a/roles/bookstack/README.md +++ b/roles/bookstack/README.md @@ -151,4 +151,4 @@ Restore procedure: ## License -MIT +MIT-0 diff --git a/roles/bookstack/meta/main.yml b/roles/bookstack/meta/main.yml index a6e941d..dad0716 100644 --- a/roles/bookstack/meta/main.yml +++ b/roles/bookstack/meta/main.yml @@ -2,7 +2,7 @@ galaxy_info: author: digitalboard description: Deploy BookStack as a self-contained Docker Compose stack behind Traefik company: digitalboard - license: MIT + license: MIT-0 min_ansible_version: "2.14" diff --git a/roles/collabora/README.md b/roles/collabora/README.md index 225dd44..d216c49 100644 --- a/roles/collabora/README.md +++ b/roles/collabora/README.md @@ -1,38 +1,42 @@ -Role Name -========= +# collabora -A brief description of the role goes here. +Deploys [Collabora Online](https://www.collaboraonline.com/) (CODE, +`collabora/code`) via Docker Compose behind Traefik. Collabora is the +WOPI backend that renders office documents for Nextcloud and OpenCloud. -Requirements ------------- +The role templates `coolwsd.xml` to declare which WOPI hosts may call +Collabora and which origins may embed it in an iframe. -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +## Role variables -Role Variables --------------- +| Variable | Default | Description | +| --- | --- | --- | +| `collabora_domains` | `[office.local.test]` | FQDNs the router accepts; first is canonical. | +| `collabora_image` | `collabora/code:latest` | Container image. | +| `collabora_port` | `9980` | Container port Traefik forwards to. | +| `collabora_traefik_network` | `proxy` | Docker network shared with Traefik. | +| `collabora_use_ssl` | `true` | Enable the TLS resolver on the router. | +| `collabora_ssl_verification` | `true` | Verify TLS on WOPI callbacks (false for self-signed). | +| `collabora_allowed_domains` | `[nextcloud.local.test]` | WOPI hosts allowed to call Collabora (regex). | +| `collabora_frame_ancestors` | `[nextcloud.local.test]` | Origins allowed to embed Collabora in an iframe. | +| `collabora_extra_hosts` | `[]` | Extra `host:ip` entries for in-container DNS. | -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +## Example -Dependencies ------------- +```yaml +- hosts: services + become: true + roles: + - role: digitalboard.core.collabora + vars: + collabora_domains: + - "office.example.com" + collabora_allowed_domains: + - "cloud.example.com" + collabora_frame_ancestors: + - "cloud.example.com" +``` -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +## License -Example Playbook ----------------- - -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: - - - hosts: servers - roles: - - { role: username.rolename, x: 42 } - -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/collabora/meta/main.yml b/roles/collabora/meta/main.yml index 6f91fd3..0dc353e 100644 --- a/roles/collabora/meta/main.yml +++ b/roles/collabora/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy Collabora Online (CODE) as a WOPI backend via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - 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: + - collabora + - office + - wopi + - nextcloud + - docker + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/drawio/meta/main.yml b/roles/drawio/meta/main.yml index 6f91fd3..b61826f 100644 --- a/roles/drawio/meta/main.yml +++ b/roles/drawio/meta/main.yml @@ -1,35 +1,26 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy the draw.io diagram editor via Docker Compose behind Traefik, with optional authentik ForwardAuth + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - 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: + - drawio + - diagrams + - docker + - traefik + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/garage/README.md b/roles/garage/README.md index 4996eb8..35fdc04 100644 --- a/roles/garage/README.md +++ b/roles/garage/README.md @@ -20,8 +20,10 @@ common overrides: ### Service -- `garage_s3_domains`: FQDNs the S3 router accepts. First entry is the - canonical hostname and is used as `root_domain` in `garage.toml`. +- `garage_s3_domains`: FQDNs the S3 router accepts. The first entry is the + canonical hostname; `garage.toml` derives the virtual-hosted-style S3 + `root_domain` from it as `.s3.` (so buckets resolve under + `.s3.`). - `garage_web_domain`, `garage_webui_domain`: separate hostnames for the S3-website endpoint and the console. - `garage_image`, `garage_replication_factor`, `garage_db_engine`, diff --git a/roles/garage/defaults/main.yml b/roles/garage/defaults/main.yml index 5a207eb..3820a03 100644 --- a/roles/garage/defaults/main.yml +++ b/roles/garage/defaults/main.yml @@ -14,8 +14,9 @@ garage_docker_volume_dir: "{{ docker_volume_base_dir }}/{{ garage_service_name } # Garage service configuration garage_image: "dxflrs/garage:v2.1.0" # FQDNs the garage S3 router accepts. The first entry is the canonical -# domain and is also used as the virtual-hosted-style root_domain in -# garage.toml; further entries cover internal *.int.* names. +# domain; garage.toml derives the virtual-hosted-style S3 root_domain +# from it as ".s3."; further entries cover internal +# *.int.* names. garage_s3_domains: - "storage.local.test" garage_web_domain: "web.storage.local.test" diff --git a/roles/garage/meta/argument_specs.yml b/roles/garage/meta/argument_specs.yml index 8441495..b5cb0f5 100644 --- a/roles/garage/meta/argument_specs.yml +++ b/roles/garage/meta/argument_specs.yml @@ -35,9 +35,9 @@ argument_specs: default: ['storage.local.test'] description: - FQDNs the garage S3 router accepts. The first entry is the - canonical domain and is used as the virtual-hosted-style - C(root_domain) in C(garage.toml). Further entries cover internal - C(*.int.*) names. + canonical domain; C(garage.toml) derives the virtual-hosted-style + S3 C(root_domain) from it as C(.s3.). Further entries + cover internal C(*.int.*) names. garage_web_domain: type: str default: web.storage.local.test diff --git a/roles/garage/meta/main.yml b/roles/garage/meta/main.yml index 36b9858..5442c5f 100644 --- a/roles/garage/meta/main.yml +++ b/roles/garage/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy Garage S3-compatible object storage via Docker Compose, with declarative key/bucket provisioning + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.1 - - # 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: + - garage + - s3 + - storage + - object-storage + - docker + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/homarr/README.md b/roles/homarr/README.md index 77d6447..774b598 100644 --- a/roles/homarr/README.md +++ b/roles/homarr/README.md @@ -36,8 +36,10 @@ secrets to version control.** | `homarr_admin_password` | strong password | `openssl rand -base64 24` | | `homarr_oidc_client_secret` | from your identity provider | — | -The `assert` task at the top of the role will fail fast if the encryption -key is missing or malformed. +`homarr_oidc_client_secret` is only required when `oidc` is in +`homarr_auth_providers`; the role asserts it then. The encryption key is +always required — the `assert` task at the top of the role fails fast if it +is missing or malformed. ## Configurable variables @@ -113,7 +115,7 @@ The filter is invoked once from `tasks/main.yml`: ```yaml - 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 }}" ``` This produces a `homarr_layout` fact with two keys, both consumed by @@ -121,14 +123,14 @@ This produces a `homarr_layout` fact with two keys, both consumed by | Key | Shape | Purpose | |---|---|---| -| `apps` | list, same order as `homarr_apps` | each entry enriched with `desktop`, `tablet`, `mobile` sub-dicts of `{x, y, w, h}` | +| `apps` | list, same order as `homarr_apps` | each entry gains `desktop`/`tablet`/`mobile` dicts of `{x, y, w, h}` | | `section_height` | dict with `desktop`, `tablet`, `mobile` | minimum height of the parent section so all tiles fit | The filter signature accepts custom column counts if Homarr ever changes the breakpoint widths: ```jinja -{{ homarr_apps | homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }} +{{ homarr_apps | digitalboard.core.homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }} ``` To debug a layout without running the full deploy, run the play with @@ -241,4 +243,8 @@ and lowercase are accepted. **App tiles overlap.** Check `homarr_apps` for duplicate `id` values. The role validates this, but if you bypass the check, the seed will -still run and Homarr will display only one of the duplicates. \ No newline at end of file +still run and Homarr will display only one of the duplicates. + +## License + +MIT-0 diff --git a/roles/homarr/meta/main.yml b/roles/homarr/meta/main.yml index faea947..4818e67 100644 --- a/roles/homarr/meta/main.yml +++ b/roles/homarr/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy the Homarr dashboard via Docker Compose behind Traefik, with seeded admin user and OIDC group + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - 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: + - homarr + - dashboard + - oidc + - docker + - traefik + - digitalboard 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/httpbin/README.md b/roles/httpbin/README.md index 225dd44..45f9286 100644 --- a/roles/httpbin/README.md +++ b/roles/httpbin/README.md @@ -1,38 +1,30 @@ -Role Name -========= +# httpbin -A brief description of the role goes here. +Deploys [httpbin](https://httpbin.org/) (`kennethreitz/httpbin`) via +Docker Compose behind Traefik. Useful as a throwaway endpoint to verify +that the Traefik ingress path, TLS and routing work end to end. -Requirements ------------- +## Role variables -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +| Variable | Default | Description | +| --- | --- | --- | +| `httpbin_domain` | `httpbin.local.test` | FQDN the Traefik router matches. | +| `httpbin_image` | `kennethreitz/httpbin` | Container image. | +| `httpbin_port` | `80` | Container port Traefik forwards to. | +| `httpbin_traefik_network` | `proxy` | Docker network shared with Traefik. | +| `httpbin_use_ssl` | `true` | Route via the `websecure` entrypoint with `tls=true` (otherwise `web`). | -Role Variables --------------- +## Example -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +```yaml +- hosts: services + become: true + roles: + - role: digitalboard.core.httpbin + vars: + httpbin_domain: "httpbin.example.com" +``` -Dependencies ------------- +## License -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. - -Example Playbook ----------------- - -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: - - - hosts: servers - roles: - - { role: username.rolename, x: 42 } - -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/httpbin/meta/main.yml b/roles/httpbin/meta/main.yml index 36b9858..81e9ee6 100644 --- a/roles/httpbin/meta/main.yml +++ b/roles/httpbin/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy httpbin HTTP request/response testing service via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.1 - - # 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: + - httpbin + - testing + - debug + - docker + - traefik + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/keycloak/README.md b/roles/keycloak/README.md index 860a0b1..a94689f 100644 --- a/roles/keycloak/README.md +++ b/roles/keycloak/README.md @@ -1,65 +1,119 @@ -Keycloak -========= +# Keycloak -Ansible role to deploy Keycloak with PostgreSQL database using Docker Compose. +Ansible role to deploy Keycloak with a PostgreSQL backend via Docker +Compose, published behind Traefik. Optionally provisions realm resources +(groups, users, OIDC clients, identity providers, LDAP user federations) +through the `community.general` Keycloak modules. -Requirements ------------- +## Requirements -- Docker and Docker Compose installed on the target host -- Ansible collection: `community.docker` -- Traefik reverse proxy (for external access) +- Docker and Docker Compose on the target host (e.g. via + `digitalboard.core.base`) +- Ansible collections: `community.docker`, and `community.general` when + `keycloak_provisioning_enabled` is true +- Traefik reverse proxy with the `proxy` network already created (for + external access) -Role Variables --------------- +## Role variables -Key variables defined in `defaults/main.yml`: +Key variables from `defaults/main.yml`: -**Base Configuration:** -- `docker_compose_base_dir`: Base directory for Docker Compose files (default: `/etc/docker/compose`) -- `docker_volume_base_dir`: Base directory for Docker volumes (default: `/srv/data`) +### Base configuration -**Keycloak Configuration:** -- `keycloak_service_name`: Service name (default: `keycloak`) -- `keycloak_domain`: Domain name for Keycloak (default: `auth.digitalboard.ch`) -- `keycloak_image`: Keycloak Docker image (default: `quay.io/keycloak/keycloak:24.0.1`) -- `keycloak_port`: Internal Keycloak port (default: `8080`) -- `keycloak_admin_user`: Admin username (default: `admin`) -- `keycloak_admin_password`: Admin password (default: `changeme`) -- `keycloak_log_level`: Log level (default: `INFO`) -- `keycloak_proxy_mode`: Proxy mode (default: `edge`) +| Variable | Default | Description | +| --- | --- | --- | +| `docker_compose_base_dir` | `/etc/docker/compose` | Base dir for Compose projects. | +| `docker_volume_base_dir` | `/srv/data` | Base dir for persistent volumes. | +| `keycloak_service_name` | `keycloak` | Compose/service name; builds the per-service paths. | -**PostgreSQL Configuration:** -- `keycloak_postgres_image`: PostgreSQL Docker image (default: `postgres:15`) -- `keycloak_postgres_db`: Database name (default: `keycloak`) -- `keycloak_postgres_user`: Database user (default: `keycloak`) -- `keycloak_postgres_password`: Database password (default: `changeme`) +### Keycloak -**Traefik Configuration:** -- `keycloak_traefik_network`: Traefik network name (default: `proxy`) -- `keycloak_backend_network`: Backend network name (default: `backend`) -- `keycloak_use_ssl`: Enable SSL (default: `true`) -- `keycloak_cert_resolver`: Certificate resolver name (default: `dns`) +| Variable | Default | Description | +| --- | --- | --- | +| `keycloak_domain` | `keycloak.local.test` | Host rule and `KC_HOSTNAME`. | +| `keycloak_image` | `quay.io/keycloak/keycloak:24.0.1` | Keycloak image. | +| `keycloak_port` | `8080` | Internal HTTP port advertised to Traefik. | +| `keycloak_admin_user` | `admin` | Bootstrap admin user. | +| `keycloak_admin_password` | `changeme` | Admin password — **override this**. | +| `keycloak_log_level` | `INFO` | `KC_LOG_LEVEL`. | +| `keycloak_proxy_mode` | `edge` | `KC_PROXY` mode. | +| `keycloak_gzip_enabled` | `false` | Toggle Keycloak GZIP response encoding. | +| `keycloak_truststore_certificates` | `[]` | Host PEM paths mounted into the truststore (`KC_TRUSTSTORE_PATHS`). | +| `keycloak_extra_hosts` | `[]` | Extra `host:ip` entries for the container. | -Dependencies ------------- +### PostgreSQL -This role requires the Traefik reverse proxy to be configured and the `proxy` network to be created. +| Variable | Default | Description | +| --- | --- | --- | +| `keycloak_postgres_image` | `postgres:15` | PostgreSQL image. | +| `keycloak_postgres_db` | `keycloak` | Database name. | +| `keycloak_postgres_user` | `keycloak` | Database user. | +| `keycloak_postgres_password` | `changeme` | Database password — **override this**. | -Example Playbook ----------------- +### Traefik + +| Variable | Default | Description | +| --- | --- | --- | +| `keycloak_traefik_network` | `proxy` | External Traefik network. | +| `keycloak_backend_network` | `backend` | Internal network to PostgreSQL. | +| `keycloak_use_ssl` | `true` | Route on `websecure` with `tls=true` instead of `web`. | + +TLS is requested from Traefik via `tls=true`; the role does not set a +certificate resolver, so Traefik issues/serves the certificate according +to its own configuration. + +### Provisioning (optional) + +Provisioning runs only when `keycloak_provisioning_enabled` is true. The +tasks wait for the `/health/ready` endpoint and then call the +`community.general.keycloak_*` modules, delegated to `localhost` against +`keycloak_auth_url` (derived from `keycloak_use_ssl` + `keycloak_domain`). + +| Variable | Default | Description | +| --- | --- | --- | +| `keycloak_provisioning_enabled` | `false` | Enable realm provisioning. | +| `keycloak_realm` | `default` | Target realm; created unless `master`. | +| `keycloak_realm_display_name` | `Default Realm` | Realm display name. | +| `keycloak_auth_url` | derived | API base URL for provisioning. | +| `keycloak_groups` | `[]` | Groups to create. | +| `keycloak_local_users` | `[]` | Local users to create. | +| `keycloak_oidc_clients` | `[]` | OIDC clients to create. | +| `keycloak_identity_providers` | `[]` | Identity providers (e.g. Entra ID). | +| `keycloak_user_federations` | `[]` | LDAP user federations. | +| `keycloak_removed_users` | `[]` | Usernames to delete. | +| `keycloak_removed_groups` | `[]` | Group names to delete. | +| `keycloak_removed_clients` | `[]` | Client IDs to delete. | +| `keycloak_removed_identity_providers` | `[]` | IdP aliases to delete. | +| `keycloak_removed_user_federations` | `[]` | Federation names to delete. | + +See `defaults/main.yml` for the full entry shape of each list. + +## Dependencies + +This role requires the Traefik reverse proxy to be configured and the +`proxy` network to be created beforehand (it is referenced as an external +network in the Compose file). The `backend` network is created by the +Compose project itself. + +## Example playbook ```yaml - hosts: backend_servers roles: - - role: keycloak + - role: digitalboard.core.keycloak vars: keycloak_domain: "auth.example.com" - keycloak_admin_password: "secure_password" - keycloak_postgres_password: "secure_db_password" + keycloak_admin_password: "{{ vault_keycloak_admin_password }}" + keycloak_postgres_password: "{{ vault_keycloak_pg_password }}" + keycloak_provisioning_enabled: true + keycloak_oidc_clients: + - client_id: nextcloud + name: "Nextcloud" + client_secret: "{{ vault_nextcloud_client_secret }}" + redirect_uris: + - "https://nextcloud.example.com/apps/user_oidc/code" ``` -License -------- +## License MIT-0 diff --git a/roles/keycloak/meta/main.yml b/roles/keycloak/meta/main.yml index 36b9858..fc62b75 100644 --- a/roles/keycloak/meta/main.yml +++ b/roles/keycloak/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy Keycloak with a PostgreSQL backend via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.1 - - # 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: + - keycloak + - oidc + - sso + - docker + - traefik + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index 79214c4..f4cafa9 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -15,9 +15,10 @@ backends. the stored value differs, so re-runs don't churn - Sets up notify_push (when enabled) - Applies an in-container PHP source workaround for the upstream - `UserConfig::getValueBool` TypeError on Nextcloud 33.0.3 (idempotent - via grep guard; remove the patch task once the deployed image - ships the upstream fix) + `UserConfig::getValueBool` TypeError (nextcloud/server#59629, fixed in + master via PR #59646 with no stable33 backport before 33.0.4). + Idempotent via grep guard; remove the patch task once + `nextcloud_image` is >= 33.0.4. ## Requirements diff --git a/roles/nextcloud/meta/main.yml b/roles/nextcloud/meta/main.yml new file mode 100644 index 0000000..1acd37d --- /dev/null +++ b/roles/nextcloud/meta/main.yml @@ -0,0 +1,28 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: digitalboard + description: Deploy Nextcloud (fpm) + Redis + Postgres via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 + + min_ansible_version: "2.14" + + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble + + galaxy_tags: + - nextcloud + - files + - collabora + - oidc + - docker + - traefik + - digitalboard + +dependencies: [] diff --git a/roles/opencloud/README.md b/roles/opencloud/README.md index 225dd44..6969124 100644 --- a/roles/opencloud/README.md +++ b/roles/opencloud/README.md @@ -1,38 +1,43 @@ -Role Name -========= +# opencloud -A brief description of the role goes here. +Deploys [OpenCloud](https://opencloud.eu/) (`opencloudeu/opencloud`) as a +self-contained file platform via Docker Compose behind Traefik. Supports +the built-in IdP or external OIDC, optional S3 storage, external LDAP, +Collabora and draw.io integration, and OIDC-claim-based role assignment. -Requirements ------------- +## Role variables -Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required. +A selection of the most relevant variables — see +[defaults/main.yml](defaults/main.yml) for the full set. -Role Variables --------------- +| Variable | Default | Description | +| --- | --- | --- | +| `opencloud_domain` | `opencloud.local.test` | FQDN the Traefik router matches. | +| `opencloud_image` | `opencloudeu/opencloud:latest` | Container image. | +| `opencloud_port` | `9200` | Container port Traefik forwards to. | +| `opencloud_admin_password` | `admin` | Initial admin password — **override this**. | +| `opencloud_traefik_network` | `proxy` | Docker network shared with Traefik. | +| `opencloud_use_ssl` | `true` | Enable the TLS resolver on the router. | +| `opencloud_oidc_issuer` | `""` | External OIDC issuer; empty uses the built-in IdP. | +| `opencloud_use_s3_storage` | `false` | Use S3 storage instead of local disk. | +| `opencloud_ldap_uri` | `""` | External LDAP URI; empty uses the built-in directory. | +| `opencloud_collabora_domain` | `""` | Collabora server domain; set with `opencloud_wopi_domain` to enable editing. | +| `opencloud_wopi_domain` | `""` | WOPI server FQDN; required alongside `opencloud_collabora_domain`. | +| `opencloud_drawio_url` | `""` | draw.io URL; set to enable diagram editing. | +| `opencloud_role_assignment_driver` | `default` | Set to `oidc` to map OIDC claims to roles. | -A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well. +## Example -Dependencies ------------- +```yaml +- hosts: services + become: true + roles: + - role: digitalboard.core.opencloud + vars: + opencloud_domain: "opencloud.example.com" + opencloud_admin_password: "{{ vault_opencloud_admin_password }}" +``` -A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles. +## License -Example Playbook ----------------- - -Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too: - - - hosts: servers - roles: - - { role: username.rolename, x: 42 } - -License -------- - -BSD - -Author Information ------------------- - -An optional section for the role authors to include contact information, or a website (HTML is not allowed). +MIT-0 diff --git a/roles/opencloud/meta/main.yml b/roles/opencloud/meta/main.yml index 6f91fd3..3322149 100644 --- a/roles/opencloud/meta/main.yml +++ b/roles/opencloud/meta/main.yml @@ -1,35 +1,27 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy OpenCloud file platform via Docker Compose behind Traefik + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - 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: + - opencloud + - files + - storage + - docker + - traefik + - digitalboard dependencies: [] - # List your role dependencies here, one per line. Be sure to remove the '[]' above, - # if you add dependencies to this list. diff --git a/roles/opnform/README.md b/roles/opnform/README.md index 0722178..3773ed7 100644 --- a/roles/opnform/README.md +++ b/roles/opnform/README.md @@ -46,10 +46,14 @@ 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_jwt_secret` | 32-byte hex string | `openssl rand -hex 32` | +| `opnform_front_api_secret` | 32-byte hex string | `openssl rand -hex 32` | | `opnform_db_password` | strong password | `openssl rand -base64 24` | +`opnform_app_key` MUST keep the `base64:` prefix — the validation task +asserts it. `opnform_jwt_secret` and `opnform_front_api_secret` have no +enforced format; any sufficiently random value works. + When `opnform_oidc_enabled` is `true`: | Variable | Source | @@ -209,3 +213,7 @@ opnform_db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', 'digitalboard/opnform', mount_point='kv').data.data.db_password }}" ``` + +## License + +MIT-0 diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml index 91901c5..71048e5 100644 --- a/roles/opnform/tasks/main.yml +++ b/roles/opnform/tasks/main.yml @@ -15,10 +15,11 @@ - opnform_front_api_secret | length > 0 - opnform_db_password | length > 0 fail_msg: >- - OpnForm requires opnform_app_key (prefix 'base64:'), opnform_jwt_secret, + OpnForm requires opnform_app_key, 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:" + Generate with: + opnform_app_key='base64:'$(openssl rand -base64 32) (the 'base64:' prefix is required); + opnform_jwt_secret and opnform_front_api_secret via openssl rand -hex 32. Provide via OpenBao, Ansible Vault or extra-vars. success_msg: Secrets validation passed diff --git a/roles/send/README.md b/roles/send/README.md index 339628b..fbd6362 100644 --- a/roles/send/README.md +++ b/roles/send/README.md @@ -48,13 +48,17 @@ With S3 (Garage) backend: ```yaml send_storage_backend: s3 -send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domain'] }}" +send_s3_endpoint: "http://{{ hostvars['backend']['garage_s3_domains'][0] }}" 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'] }}" ``` +When `send_storage_backend: s3`, the role asserts that `send_s3_endpoint`, +`send_s3_bucket`, `send_s3_access_key` and `send_s3_secret_key` are all set, +and fails early otherwise. + License ------- -MIT +MIT-0 diff --git a/roles/send/meta/main.yml b/roles/send/meta/main.yml index 20f9e67..defda67 100644 --- a/roles/send/meta/main.yml +++ b/roles/send/meta/main.yml @@ -2,7 +2,7 @@ galaxy_info: author: digitalboard description: Deploy a self-hosted Send (timvisee fork) instance with Redis via Docker Compose - license: MIT + license: MIT-0 min_ansible_version: "2.14" diff --git a/roles/traefik/README.md b/roles/traefik/README.md index 9266d18..b5d8226 100644 --- a/roles/traefik/README.md +++ b/roles/traefik/README.md @@ -54,10 +54,15 @@ common overrides: ## Dependencies -- Traefik network (`traefik_network`, default `proxy`) must be created - by the `base` role or by hand before this role runs. -- In `dmz` mode, the proxied backend services advertise themselves via - the `traefik_services` host_var on each backend host. +- Run `digitalboard.core.base` first (or otherwise install Docker and the + `community.docker` collection); this role manages containers and networks + through `community.docker`. +- The Traefik network (`traefik_network`, default `proxy`) is created by + this role (`community.docker.docker_network`, state present), so no + pre-creation is required. +- In `dmz` mode, backend hosts advertise the services to aggregate via the + `traefik_dmz_exposed_services` host_var; `traefik_services` defines extra + routes directly on the DMZ host (each entry must set `backend_host`). ## Example playbook diff --git a/roles/traefik/meta/argument_specs.yml b/roles/traefik/meta/argument_specs.yml index 3d0442a..d9443ff 100644 --- a/roles/traefik/meta/argument_specs.yml +++ b/roles/traefik/meta/argument_specs.yml @@ -109,10 +109,11 @@ argument_specs: type: bool default: false description: - - Disable lego's propagation check against the zone's authoritative - nameservers (sets C(LEGO_DISABLE_CNAME_SUPPORT=) plus the - authoritative-NS-check skip). Use when the SOA-listed NS hostname - resolves to an address the proxy host cannot reach. + - "Sets C(propagation.disableANSChecks) to true on the ACME resolver + in the static config, disabling lego's propagation check against + the zone's authoritative nameservers. Use when the SOA-listed NS + hostname resolves to an address the proxy host cannot reach; lego + still polls via the configured C(resolvers) list." traefik_selfsigned_cert_dir: type: path diff --git a/roles/traefik/meta/main.yml b/roles/traefik/meta/main.yml index 7c2fc0d..c5ed5b1 100644 --- a/roles/traefik/meta/main.yml +++ b/roles/traefik/meta/main.yml @@ -1,33 +1,26 @@ #SPDX-License-Identifier: MIT-0 galaxy_info: - author: your name - description: your role description - company: your company (optional) + author: digitalboard + description: Deploy Traefik v3 as a DMZ or backend reverse proxy via Docker Compose + company: Digitalboard + license: MIT-0 - # 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 + min_ansible_version: "2.14" - # 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) + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble - min_ansible_version: 2.1 - - # 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: + - traefik + - reverseproxy + - ingress + - docker + - digitalboard dependencies: [] From 3ace667b6c46c577e5d033744031550b05c6fcea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 2 Jun 2026 13:44:08 +0200 Subject: [PATCH 28/29] feat(services): refine split-horizon OIDC routing and harden nextcloud patch - authentik: address the rewrite service by compose service name instead of a network alias on the public FQDN, which shadowed extra_hosts pins and broke OIDC discovery for c-ares-based (Node) resolvers - homarr: add homarr_extra_hosts to pin the IdP FQDN to a LAN IP so OIDC discovery stays in-network while the issuer matches the browser-facing URL - opnform: add opnform_oidc_sso_redirect_root to 302 the root URL to the SSO path (deep-links untouched, /login?bypass=1 break-glass); restart ingress via container restart so envsubst re-renders nginx.conf - nextcloud: make the UserConfig sed workaround fail loud on upstream drift instead of silently skipping (nextcloud/server#59629) - gitignore: exclude the local .ansible/ collection cache --- .gitignore | 3 + .../authentik/templates/docker-compose.yml.j2 | 31 ++-- roles/homarr/README.md | 1 + roles/homarr/defaults/main.yml | 4 + roles/homarr/templates/docker-compose.yml.j2 | 6 + roles/nextcloud/tasks/main.yml | 23 ++- roles/opnform/README.md | 10 +- roles/opnform/defaults/main.yml | 7 + roles/opnform/handlers/main.yml | 10 ++ roles/opnform/meta/argument_specs.yml | 10 ++ roles/opnform/tasks/main.yml | 159 +++++++++++++++--- roles/opnform/templates/nginx.conf.j2 | 49 +++++- 12 files changed, 264 insertions(+), 49 deletions(-) diff --git a/.gitignore b/.gitignore index a84afb0..e510871 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__/ *.pyc plugins/lookup/__pycache__/ + +# Local Ansible collection cache (galaxy/collection resolver) +/.ansible/ diff --git a/roles/authentik/templates/docker-compose.yml.j2 b/roles/authentik/templates/docker-compose.yml.j2 index e5b8a11..cd3ef1e 100644 --- a/roles/authentik/templates/docker-compose.yml.j2 +++ b/roles/authentik/templates/docker-compose.yml.j2 @@ -44,13 +44,14 @@ services: condition: service_healthy networks: {{ authentik_backend_network }}: {} - # Network alias so traefik (which shares this network) can resolve - # the canonical FQDN to this container directly. The URL-based - # service below uses that to send upstream traffic with a fixed - # Host header equal to the canonical hostname. - {{ authentik_traefik_network }}: - aliases: - - {{ authentik_domains[0] }} + # No alias for the public FQDN here: that would shadow `/etc/hosts` + # pins (extra_hosts) in other containers sharing this network and + # break OIDC discovery for Node-based clients (c-ares-based + # resolvers consult Docker DNS before /etc/hosts). The URL-based + # service below addresses this container by its compose service + # name `server`, which Docker exposes as an alias on every network + # the container joins. + {{ authentik_traefik_network }}: {} labels: - traefik.enable=true - traefik.docker.network={{ authentik_traefik_network }} @@ -68,14 +69,14 @@ services: - traefik.http.services.{{ authentik_service_name }}.loadbalancer.server.port={{ authentik_port }} {% if authentik_host_rewrite_domains | length > 0 %} # Server-to-server entry: a separate service points at this very - # container by the canonical FQDN (resolved via the network alias - # above) and disables passHostHeader so the upstream Host header - # becomes `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer - # URLs from X-Forwarded-Host (not Host), so we also pin that header - # via middleware. Together this keeps the iss claim aligned with - # the public hostname browsers see during login, even when the - # request itself arrived on an internal *.int.* FQDN. - - traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.server.url=http://{{ authentik_domains[0] }}:{{ authentik_port }} + # container by its compose service name `server` and disables + # passHostHeader so the upstream Host header becomes + # `{{ authentik_domains[0] }}`. Authentik builds OIDC issuer URLs + # from X-Forwarded-Host (not Host), so we also pin that header via + # middleware. Together this keeps the iss claim aligned with the + # public hostname browsers see during login, even when the request + # itself arrived on an internal *.int.* FQDN. + - traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.server.url=http://server:{{ authentik_port }} - traefik.http.services.{{ authentik_service_name }}-rewrite.loadbalancer.passhostheader=false - traefik.http.middlewares.{{ authentik_service_name }}-xfh-rewrite.headers.customrequestheaders.X-Forwarded-Host={{ authentik_domains[0] }} {% for d in authentik_host_rewrite_domains %} diff --git a/roles/homarr/README.md b/roles/homarr/README.md index 774b598..a1e1dcf 100644 --- a/roles/homarr/README.md +++ b/roles/homarr/README.md @@ -49,6 +49,7 @@ See `defaults/main.yml` for the full list. Most useful overrides: |---|---|---| | `homarr_domain` | `homarr.local.test` | Traefik Host rule | | `homarr_extra_domains` | `[]` | Extra Host-rule hostnames (OR-combined), e.g. internal `*.int.*` FQDN | +| `homarr_extra_hosts` | `[]` | Container `/etc/hosts` overrides (`host:ip`) — pin IdP FQDN to LAN IP | | `homarr_base_url` | `https://home.local.test` | NEXTAUTH_URL / BASE_URL | | `homarr_auth_providers` | `credentials` | `credentials`, `oidc`, or both | | `homarr_oidc_issuer` | empty | Identity provider issuer URL | diff --git a/roles/homarr/defaults/main.yml b/roles/homarr/defaults/main.yml index 3d22ee7..a7b2f2b 100644 --- a/roles/homarr/defaults/main.yml +++ b/roles/homarr/defaults/main.yml @@ -19,6 +19,10 @@ homarr_domain: "homarr.local.test" # *.int.* FQDN so a DMZ reverseproxy can hit a backend hostname covered # by the cert). homarr_extra_domains: [] +# Extra /etc/hosts entries inside the homarr container (format "host:ip"). +# Used to pin the IdP's public FQDN to a LAN IP so OIDC discovery stays +# in-network while the issuer URL matches what browsers see. +homarr_extra_hosts: [] homarr_image: "ghcr.io/homarr-labs/homarr:latest" homarr_port: 7575 homarr_use_docker: false diff --git a/roles/homarr/templates/docker-compose.yml.j2 b/roles/homarr/templates/docker-compose.yml.j2 index 5907763..2021de0 100644 --- a/roles/homarr/templates/docker-compose.yml.j2 +++ b/roles/homarr/templates/docker-compose.yml.j2 @@ -26,6 +26,12 @@ services: AUTH_OIDC_AUTO_LOGIN: "{{ homarr_oidc_auto_login | default('false') }}" networks: - {{ homarr_traefik_network }} +{% if homarr_extra_hosts | default([]) | length > 0 %} + extra_hosts: +{% for h in homarr_extra_hosts %} + - "{{ h }}" +{% endfor %} +{% endif %} labels: - traefik.enable=true - traefik.docker.network={{ homarr_traefik_network }} diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index 7ee804c..c43bc4d 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -66,13 +66,32 @@ - name: Check UserConfig.php patch status per container ansible.builtin.shell: + # rc 0 -> already patched; rc 1 -> still the unpatched original; rc 2 -> + # neither marker present (upstream drift -> the guard task below fails loud). cmd: >- - docker exec {{ item }} grep -q "strtolower((string)" /var/www/html/lib/private/Config/UserConfig.php + docker exec {{ item }} sh -c ' + grep -q "strtolower((string)\$this->getTypedValue" /var/www/html/lib/private/Config/UserConfig.php && exit 0; + grep -q "strtolower(\$this->getTypedValue" /var/www/html/lib/private/Config/UserConfig.php && exit 1; + exit 2' loop: "{{ _nextcloud_php_containers.stdout_lines }}" register: _nextcloud_userconfig_check changed_when: false failed_when: false +- name: Fail if the UserConfig.php source drifted from the expected upstream line + ansible.builtin.fail: + msg: >- + Neither the patched nor the expected original strtolower($this->getTypedValue(...)) + line was found in {{ item.item }}:/var/www/html/lib/private/Config/UserConfig.php. + The nextcloud/server#59629 workaround can no longer locate its target — the upstream + source likely changed. Re-verify whether the fix shipped (then drop this block) or + update the sed expression. Silently skipping would let the TypeError regress. + loop: "{{ _nextcloud_userconfig_check.results }}" + loop_control: + label: "{{ item.item }}" + when: + - item.rc | default(2) == 2 + - name: Apply UserConfig::getValueBool string-cast workaround ansible.builtin.shell: cmd: >- @@ -83,7 +102,7 @@ loop_control: label: "{{ item.item }}" when: - - item.rc | default(1) != 0 + - item.rc | default(2) == 1 - name: Wait for Nextcloud to be ready ansible.builtin.shell: diff --git a/roles/opnform/README.md b/roles/opnform/README.md index 3773ed7..707d736 100644 --- a/roles/opnform/README.md +++ b/roles/opnform/README.md @@ -167,10 +167,16 @@ calls `/api/auth/{slug}/redirect` (no domain check) and forwards the browser to the IdP authorize URL. ```yaml -opnform_oidc_sso_entrypoint: true # default false -opnform_oidc_sso_path: "/sso" # link users to https:///sso +opnform_oidc_sso_entrypoint: true # default false +opnform_oidc_sso_path: "/sso" # link users to https:///sso +opnform_oidc_sso_redirect_root: true # default false — root URL 302s to ``` +With `opnform_oidc_sso_redirect_root` enabled both the bare hostname +and `/login` jump straight to the IdP. Public form deep-links +(`/forms/`, `/admin/...`) are not touched. The email form remains +reachable as a break-glass path via `/login?bypass=1`. + ## Networking / split-horizon ```yaml diff --git a/roles/opnform/defaults/main.yml b/roles/opnform/defaults/main.yml index 9a79b07..25529a5 100644 --- a/roles/opnform/defaults/main.yml +++ b/roles/opnform/defaults/main.yml @@ -130,6 +130,13 @@ opnform_oidc_group_role_mappings: [] opnform_oidc_sso_entrypoint: false opnform_oidc_sso_path: "/sso" +# When true, the ingress 302-redirects the root URL (exact-match on `/`) +# to opnform_oidc_sso_path so visiting https:/// jumps straight +# to the IdP login without showing OpnForm's email form. Public form +# deep-links (`/forms/`, `/login`, etc.) are untouched. +# Requires opnform_oidc_sso_entrypoint=true. +opnform_oidc_sso_redirect_root: false + # Traefik configuration opnform_traefik_network: "proxy" opnform_use_ssl: true diff --git a/roles/opnform/handlers/main.yml b/roles/opnform/handlers/main.yml index 1c0b422..03c44b9 100644 --- a/roles/opnform/handlers/main.yml +++ b/roles/opnform/handlers/main.yml @@ -6,3 +6,13 @@ community.docker.docker_compose_v2: project_src: "{{ opnform_docker_compose_dir }}" state: restarted + +# nginx.conf is bind-mounted into the ingress container and rendered to +# /etc/nginx/conf.d/default.conf by the envsubst entrypoint on container +# start. Plain `docker restart` re-runs that entrypoint, so the new +# template is picked up without bouncing db/redis/api/ui. +- name: restart opnform ingress + community.docker.docker_container: + name: opnform-ingress + state: started + restart: true diff --git a/roles/opnform/meta/argument_specs.yml b/roles/opnform/meta/argument_specs.yml index 5e1248d..1f91e71 100644 --- a/roles/opnform/meta/argument_specs.yml +++ b/roles/opnform/meta/argument_specs.yml @@ -256,6 +256,16 @@ argument_specs: - Path (on C(opnform_domain)) where the direct-SSO redirect page is served when C(opnform_oidc_sso_entrypoint=true). Must start with C(/) and not collide with OpnForm's own routes. + opnform_oidc_sso_redirect_root: + type: bool + default: false + description: + - When true, the nginx ingress 302-redirects the root URL + (exact-match on C(/)) to C(opnform_oidc_sso_path), so visiting + C(https:///) jumps straight to the IdP without + OpnForm's email login form. Public form deep-links + (C(/forms/), C(/login), C(/admin/...)) are untouched. + Requires C(opnform_oidc_sso_entrypoint=true). opnform_traefik_network: type: str diff --git a/roles/opnform/tasks/main.yml b/roles/opnform/tasks/main.yml index 71048e5..cd5efe4 100644 --- a/roles/opnform/tasks/main.yml +++ b/roles/opnform/tasks/main.yml @@ -75,22 +75,97 @@ src: nginx.conf.j2 dest: "{{ opnform_docker_compose_dir }}/nginx.conf" mode: '0644' - notify: restart opnform + notify: restart opnform ingress # OIDC_FORCE_LOGIN disables OpnForm's password login — including the -# password-based admin/OIDC bootstrap this role performs below. So the -# first compose render always keeps force-login OFF; it is switched on -# only after the bootstrap completes (see step 7). This keeps a first -# deploy on a fresh host working even when opnform_oidc_force_login=true. -- name: Render compose with force-login disabled during bootstrap +# password-based admin/OIDC bootstrap this role performs below. The +# bootstrap must therefore run with force-login OFF. To stay idempotent +# on re-runs (avoid recreating api containers on every apply), we only +# turn force-login OFF when the bootstrap is actually needed (first run +# on a fresh host, no OIDC connection yet). Once the connection exists +# we render the final force-login value straight away, so the compose +# file is byte-identical across re-runs. +- name: Probe whether OpnForm is already bootstrapped + block: + - name: Check if opnform-api container exists and is healthy + ansible.builtin.command: + cmd: docker inspect --format='{% raw %}{{.State.Health.Status}}{% endraw %}' opnform-api + register: _opnform_api_health_probe + changed_when: false + failed_when: false + + - name: Attempt admin login (only when api is healthy) + 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_probe_login + no_log: true + when: + - _opnform_api_health_probe.rc == 0 + - _opnform_api_health_probe.stdout == "healthy" + - opnform_admin_email | length > 0 + - opnform_admin_password | length > 0 + + - name: Probe for existing OIDC connection + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces" + method: GET + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ _opnform_probe_login.json.token }}" + status_code: 200 + validate_certs: false + register: _opnform_probe_workspaces + no_log: true + when: + - opnform_oidc_enabled | bool + - _opnform_probe_login is defined + - _opnform_probe_login.status | default(0) == 200 + + - name: Probe OIDC connections on default workspace + ansible.builtin.uri: + url: "https://127.0.0.1/api/open/workspaces/{{ _opnform_probe_workspaces.json[0].id }}/oidc-connections" + method: GET + headers: + Host: "{{ opnform_domain }}" + Authorization: "Bearer {{ _opnform_probe_login.json.token }}" + status_code: 200 + validate_certs: false + register: _opnform_probe_oidc + no_log: true + when: + - opnform_oidc_enabled | bool + - _opnform_probe_workspaces is defined + - _opnform_probe_workspaces.json | default([]) | length > 0 + +- name: Decide whether force-login can render in its final state ansible.builtin.set_fact: - _opnform_force_login_effective: false + # True when force-login is desired AND admin+OIDC bootstrap has + # already completed (admin user exists with the configured password, + # OIDC connection is present). On a fresh host both checks fail and + # we fall back to false so the bootstrap below can run. + _opnform_force_login_effective: >- + {{ + (opnform_oidc_enabled | bool) + and (opnform_oidc_force_login | bool) + and (_opnform_probe_login.status | default(0) == 200) + and ((_opnform_probe_oidc.json | default([])) | length > 0) + }} - name: Deploy docker-compose file ansible.builtin.template: src: docker-compose.yml.j2 dest: "{{ opnform_docker_compose_dir }}/docker-compose.yml" mode: '0644' + register: _opnform_compose_rendered notify: restart opnform # ===================================================================== @@ -123,6 +198,12 @@ # 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. +# +# Skipped entirely when force-login already rendered in its final state +# (probe in step 2 confirmed admin + connection exist). Re-running the +# /api/login probe on a force-login-enabled api would 401 and 422, so +# avoid the noise — and avoid spurious "changed" status from a register +# call that won't help anyway. - name: Check if OpnForm admin user already exists ansible.builtin.uri: @@ -140,6 +221,7 @@ when: - opnform_admin_email | length > 0 - opnform_admin_password | length > 0 + - not (_opnform_force_login_effective | bool) - name: Create OpnForm admin user via /api/register ansible.builtin.uri: @@ -160,7 +242,8 @@ when: - opnform_admin_email | length > 0 - opnform_admin_password | length > 0 - - opnform_admin_login.status != 200 + - not (_opnform_force_login_effective | bool) + - opnform_admin_login.status | default(0) != 200 # ===================================================================== # 6. OIDC IDENTITY CONNECTION (optional) @@ -171,6 +254,13 @@ # existing one to the desired state. PATCHing (rather than skipping when # one exists) keeps inventory changes — e.g. a corrected issuer — applied # on re-runs instead of leaving stale values in the DB forever. +# +# Skipped on re-applies when force-login is already enabled — the API +# password login required for these calls is disabled, and the connection +# is known to exist (otherwise force-login wouldn't have rendered in its +# final state in step 2). To intentionally re-provision the connection +# from inventory changes on such a host: temporarily set +# opnform_oidc_force_login=false, re-apply, then set it back to true. - name: Log in as admin to obtain OIDC API token ansible.builtin.uri: @@ -186,7 +276,9 @@ validate_certs: false register: opnform_oidc_token no_log: true - when: opnform_oidc_enabled | bool + when: + - opnform_oidc_enabled | bool + - not (_opnform_force_login_effective | bool) - name: Fetch admin's workspaces ansible.builtin.uri: @@ -199,7 +291,9 @@ validate_certs: false register: opnform_workspaces no_log: true - when: opnform_oidc_enabled | bool + when: + - opnform_oidc_enabled | bool + - not (_opnform_force_login_effective | bool) - name: Fetch existing OIDC connections for the default workspace ansible.builtin.uri: @@ -212,7 +306,9 @@ validate_certs: false register: opnform_existing_oidc no_log: true - when: opnform_oidc_enabled | bool + when: + - opnform_oidc_enabled | bool + - not (_opnform_force_login_effective | bool) - name: Resolve OIDC group-role mappings ansible.builtin.set_fact: @@ -224,7 +320,9 @@ ([{'idp_group': opnform_oidc_admin_group, 'role': 'admin'}] if (opnform_oidc_admin_group | length > 0) else []) }} - when: opnform_oidc_enabled | bool + when: + - opnform_oidc_enabled | bool + - not (_opnform_force_login_effective | bool) # Desired connection state shared by both the create (POST) and update # (PATCH) calls below. client_secret is always sent: OpnForm's update @@ -244,7 +342,9 @@ require_state: true group_role_mappings: "{{ _opnform_oidc_group_role_mappings }}" no_log: true - when: opnform_oidc_enabled | bool + when: + - opnform_oidc_enabled | bool + - not (_opnform_force_login_effective | bool) - name: Create OIDC identity connection ansible.builtin.uri: @@ -260,6 +360,7 @@ no_log: true when: - opnform_oidc_enabled | bool + - not (_opnform_force_login_effective | bool) - opnform_existing_oidc.json | length == 0 # An OIDC connection already exists: PATCH it to the desired state so @@ -280,20 +381,26 @@ no_log: true when: - opnform_oidc_enabled | bool + - not (_opnform_force_login_effective | bool) - opnform_existing_oidc.json | length > 0 # ===================================================================== -# 7. ENABLE FORCE LOGIN (optional, must run last) +# 7. ENABLE FORCE LOGIN (first-run only) # ===================================================================== -# OIDC_FORCE_LOGIN disables password login — including the password-based -# admin/OIDC bootstrap above — so it is switched on only now, after the -# connection is provisioned. OpnForm itself only enforces force-login when -# an enabled OIDC connection exists, so the order matters: connection -# first, force-login second. -- name: Enable force login now that the OIDC connection exists +# On the very first apply, step 2 rendered the compose file with +# force-login disabled (so the bootstrap above could use the password +# login). Now that the OIDC connection exists, re-render the compose +# file with force-login in its final state and recreate the api +# containers once. +# +# On all subsequent applies the probe in step 2 already rendered the +# final value, the compose file is byte-identical here, and this block +# is a no-op (the template task reports "ok", no recreate). +- name: Enable force login (first run, after OIDC bootstrap) when: - opnform_oidc_enabled | bool - opnform_oidc_force_login | bool + - not (_opnform_force_login_effective | bool) block: - name: Re-render compose with force-login enabled ansible.builtin.set_fact: @@ -314,6 +421,13 @@ wait_timeout: 180 when: _opnform_force_login_compose is changed + - name: Restart ingress so nginx picks up the new api container IPs + community.docker.docker_container: + name: opnform-ingress + state: started + restart: true + when: _opnform_force_login_compose is changed + - name: Display deployment info ansible.builtin.debug: msg: |- @@ -335,8 +449,9 @@ Users with @{{ opnform_oidc_domain }} addresses will be redirected to {{ opnform_oidc_issuer }} on login. {% if opnform_oidc_sso_entrypoint %} - Direct-SSO entrypoint: {{ opnform_base_url }}{{ opnform_oidc_sso_path }} - (link users here to skip the email login form) + Login intercept active: {{ opnform_base_url }}/login forwards + directly to the IdP. Use {{ opnform_base_url }}/login?bypass=1 + as a break-glass path for the email form when the IdP is down. {% endif %} {% else %} OIDC: disabled (set opnform_oidc_enabled=true to auto-configure) diff --git a/roles/opnform/templates/nginx.conf.j2 b/roles/opnform/templates/nginx.conf.j2 index 6f62840..5aeb1bc 100644 --- a/roles/opnform/templates/nginx.conf.j2 +++ b/roles/opnform/templates/nginx.conf.j2 @@ -15,20 +15,53 @@ server { index index.html index.htm index.php; + # Re-resolve upstream container hostnames via Docker's embedded DNS + # at request time. Without this, nginx caches the first resolution + # forever; if `api` or `ui` get recreated and pick up a new IP, every + # request 502s until the ingress itself is restarted. + resolver 127.0.0.11 valid=10s ipv6=off; + set $upstream_api api; + set $upstream_ui ui; + {% if opnform_oidc_enabled and opnform_oidc_sso_entrypoint %} - # Direct-SSO entrypoint: a tiny page that asks the API for the IdP - # authorize URL (no email/domain check on this endpoint) and forwards - # the browser there. Link users here instead of /login to skip the - # email field entirely. Exact-match so it wins over the `/` prefix. - location = {{ opnform_oidc_sso_path }} { + # Root → /login. Public forms live under /forms/, so the bare + # hostname only serves the authenticated dashboard — sending it + # straight to /login (which then jumps to the IdP) saves an extra + # UI-side redirect for anyone who lands there. + location = / { + return 302 /login; + } + + # /login intercept: serve a tiny HTML page that calls OpnForm's + # /api/auth/{slug}/redirect endpoint and forwards the browser to the + # IdP authorize URL — skipping the email-based login form entirely. + # Break-glass: /login?bypass=1 falls through to the UI's own login + # form so the email/password path stays reachable when the IdP is + # down. Bypass branches to a named location (`@login_bypass`) because + # `proxy_pass` inside an `if` block is invalid nginx config. + location = /login { + if ($arg_bypass = "1") { + error_page 418 = @login_bypass; + return 418; + } default_type text/html; - return 200 'Redirecting to sign-in…

Redirecting to sign-in…

'; + return 200 'Redirecting to sign-in…

Redirecting to sign-in…

'; + } + + location @login_bypass { + proxy_http_version 1.1; + proxy_pass http://$upstream_ui:3000/login; + 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; } {% endif %} location / { proxy_http_version 1.1; - proxy_pass http://ui:3000; + proxy_pass http://$upstream_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; @@ -45,7 +78,7 @@ server { location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass api:9000; + fastcgi_pass $upstream_api:9000; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME /usr/share/nginx/html/public/index.php; From a8954f525c8afbbeee83616e4af94b64613fd629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Tue, 2 Jun 2026 17:05:44 +0200 Subject: [PATCH 29/29] fix(opnform): align FRONT_API_SECRET across api and ui SSR path The api service now also receives FRONT_API_SECRET so AuthenticateJWT accepts the UI's server-side JWT forwards instead of blacklisting them on UA mismatch. On the ui service the var is renamed FRONT_API_SECRET -> NUXT_API_SECRET so Nuxt's runtimeConfig.apiSecret is actually populated (NUXT_ convention) and injected as x-api-secret, short-circuiting the UA-fingerprint check that otherwise 401s every reload. --- roles/opnform/templates/docker-compose.yml.j2 | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/roles/opnform/templates/docker-compose.yml.j2 b/roles/opnform/templates/docker-compose.yml.j2 index 6b5866c..24ef00c 100644 --- a/roles/opnform/templates/docker-compose.yml.j2 +++ b/roles/opnform/templates/docker-compose.yml.j2 @@ -60,6 +60,14 @@ services: JWT_TTL: "1440" JWT_SECRET: "{{ opnform_jwt_secret }}" + # Shared secret for trusted SSR requests from the Nuxt UI. The UI + # forwards JWTs server-side with its own user agent; without this + # secret the API's AuthenticateJWT middleware would reject those + # requests (UA mismatch -> token blacklisted -> the next genuine + # browser request 401s). Must match FRONT_API_SECRET on the ui + # service. + FRONT_API_SECRET: "{{ opnform_front_api_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 }}" @@ -114,7 +122,13 @@ services: NUXT_PUBLIC_API_BASE: "/api" NUXT_PRIVATE_API_BASE: "http://ingress/api" NUXT_PUBLIC_ENV: production - FRONT_API_SECRET: "{{ opnform_front_api_secret }}" + # Nuxt runtimeConfig.apiSecret is fed by NUXT_API_SECRET (Nuxt + # convention: NUXT_ populates runtimeConfig.). The UI + # injects this as `x-api-secret` on SSR-side forwards to Laravel, + # which then short-circuits the UA-fingerprint check in + # AuthenticateJWT — without it every reload would invalidate the + # JWT (UA `node` vs UA at issue time) and 401. + NUXT_API_SECRET: "{{ opnform_front_api_secret }}" depends_on: api: condition: service_healthy