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 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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