From 36e3a4b688215bb394f9f903cdb7a49c598071a4 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 01/10] 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 7535b5a..1c46015 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 02d45026a559c02383ed32a3c123d02f5717650c 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 02/10] 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 1c46015..b60dbc3 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" # Non-default apps to install and enable nextcloud_apps_to_install: 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 afe5950d770a5de61273a7df4aa205cec7aec1b7 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 03/10] 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 da103a59f28006e01a850e46f7a11c214b3efdc5 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 04/10] 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 c27584cd9cd484e888c27af30f9dc143cb6a48f3 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 05/10] 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 1157448d59a3bfe296618f861942dff22c27c24a 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 06/10] 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 aea6dec081bded95c4efe3bc6ac3d9b37a6983a6 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 07/10] 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 d476bca4f5a228c54743248c62a9c72d250cd8ba 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 08/10] 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 8d2a5cd..a5c8dc9 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 1f9292cc9a7171a5e66bac63283e58d1c06626e9 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 09/10] 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 14c81657d72751cfcc2aa3a8407e9af4fa37f790 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 10/10] 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.