Compare commits

..

6 commits

Author SHA1 Message Date
Simon Bärlocher
d476bca4f5
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.
2026-05-26 14:04:33 +02:00
Simon Bärlocher
aea6dec081
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.
2026-05-26 14:04:17 +02:00
Simon Bärlocher
1157448d59
fix(garage): make bootstrap & provision idempotent across reruns
* 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`).
2026-05-26 14:03:58 +02:00
Simon Bärlocher
c27584cd9c
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.
2026-05-26 14:03:38 +02:00
Simon Bärlocher
da103a59f2
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.
2026-05-26 14:03:05 +02:00
Simon Bärlocher
afe5950d77
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.
2026-05-26 14:02:43 +02:00
18 changed files with 376 additions and 22 deletions

View file

@ -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"

View file

@ -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 %}

View file

@ -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 }}

View file

@ -18,3 +18,10 @@ drawio_extra_hosts: []
# Traefik configuration
drawio_traefik_network: "proxy"
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: ""

View file

@ -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 }}:

View file

@ -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

View file

@ -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:

View file

@ -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:

View file

@ -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<hex> 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

View file

@ -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:

View file

@ -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
changed_when: false

View file

@ -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 }}
when: _drawio_cfg.DrawioOffline != (nextcloud_drawio_offline | string)

View file

@ -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:

View file

@ -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

View file

@ -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
when: (_notify_push_current.stdout | default('') | trim) != ('https://' ~ (nextcloud_notify_push_domain | default(nextcloud_domains[0])) ~ '/push')

View file

@ -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"

View file

@ -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

View file

@ -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 }}: