From 0733d5710f773d8ca49bf665fcba27358fc6f54a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Fri, 5 Jun 2026 14:22:36 +0200 Subject: [PATCH 1/3] feat(garage,authentik): console multi-domain + standalone proxy outpost Two related additions for gating a cross-host service (garage WebUI on a storage host) behind authentik without breaking on TLS or X-Forwarded-Host: garage role: - Add garage_webui_domains (list), mirroring garage_s3_domains. The console router now accepts every entry, so a DMZ reverseproxy can use a backend_host whose cert SAN matches an internal *.int.* name instead of connecting by IP (which fails acme cert verification). Defaults to the single garage_webui_domain so existing inventories are unaffected. authentik_outpost_proxy role (new): - Standalone proxy (ForwardAuth) outpost, modelled on authentik_outpost_ldap. Co-locate it with the protected service so the ForwardAuth subrequest reaches authentik over the local docker network. The embedded outpost only works for services on the authentik host: a cross-host subrequest routed through an extra reverse-proxy hop arrives with a polluted X-Forwarded-Host (port/comma-appended), which the outpost no longer matches against the provider's external_host (404). --- roles/authentik_outpost_proxy/README.md | 58 +++++++++++++++++++ .../authentik_outpost_proxy/defaults/main.yml | 28 +++++++++ .../authentik_outpost_proxy/handlers/main.yml | 3 + roles/authentik_outpost_proxy/meta/main.yml | 28 +++++++++ roles/authentik_outpost_proxy/tasks/main.yml | 26 +++++++++ .../templates/docker-compose.yml.j2 | 22 +++++++ roles/authentik_outpost_proxy/tests/inventory | 2 + roles/authentik_outpost_proxy/tests/test.yml | 6 ++ roles/authentik_outpost_proxy/vars/main.yml | 3 + roles/garage/defaults/main.yml | 8 +++ roles/garage/meta/argument_specs.yml | 11 +++- roles/garage/templates/docker-compose.yml.j2 | 2 +- 12 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 roles/authentik_outpost_proxy/README.md create mode 100644 roles/authentik_outpost_proxy/defaults/main.yml create mode 100644 roles/authentik_outpost_proxy/handlers/main.yml create mode 100644 roles/authentik_outpost_proxy/meta/main.yml create mode 100644 roles/authentik_outpost_proxy/tasks/main.yml create mode 100644 roles/authentik_outpost_proxy/templates/docker-compose.yml.j2 create mode 100644 roles/authentik_outpost_proxy/tests/inventory create mode 100644 roles/authentik_outpost_proxy/tests/test.yml create mode 100644 roles/authentik_outpost_proxy/vars/main.yml diff --git a/roles/authentik_outpost_proxy/README.md b/roles/authentik_outpost_proxy/README.md new file mode 100644 index 0000000..93f2045 --- /dev/null +++ b/roles/authentik_outpost_proxy/README.md @@ -0,0 +1,58 @@ +# authentik_outpost_proxy + +Deploys an [authentik](https://goauthentik.io) proxy (ForwardAuth) +outpost via Docker Compose. The outpost serves the +`/outpost.goauthentik.io/auth/*` endpoints locally, so a Traefik +`ForwardAuth` middleware on the same host can gate services behind +authentik without routing the auth subrequest through additional reverse +proxies. + +Use this when the protected service runs on a **different host** than the +authentik server: the embedded outpost only works for services co-located +with authentik (the subrequest must reach it without an intermediate proxy +mangling `X-Forwarded-Host`). Co-locating a proxy outpost with the service +keeps the ForwardAuth subrequest on the local docker network. + +The outpost connects back to an authentik server using an outpost token +issued in the authentik admin interface (register the outpost there and +assign the proxy providers it should serve). The image version must match +the authentik server version. + +## Requirements + +- Docker and Docker Compose on the target host (e.g. via + `digitalboard.core.base`) +- Ansible collection: `community.docker` +- An authentik proxy outpost registered on the server, with the proxy + providers for the protected services assigned to it. + +## Role variables + +| Variable | Default | Description | +| --- | --- | --- | +| `authentik_outpost_proxy_image` | `ghcr.io/goauthentik/proxy:2026.2.2` | Outpost image (match the server version). | +| `authentik_outpost_proxy_host` | `https://authentik.local.test` | URL of the authentik server. | +| `authentik_outpost_proxy_token` | `changeme` | Outpost token — **override this**. | +| `authentik_outpost_proxy_insecure` | `"true"` | Skip TLS verification toward the authentik server. | +| `authentik_outpost_proxy_network` | `proxy` | Docker network the local Traefik routers and this outpost share. | +| `authentik_outpost_proxy_extra_hosts` | `[]` | Extra `host:ip` entries for in-container DNS. | + +The local ForwardAuth middleware then points at the container over the +shared network, e.g. +`http://authentik-outpost-proxy-proxy-1:9000/outpost.goauthentik.io/auth/traefik`. + +## Example + +```yaml +- hosts: storage + become: true + roles: + - role: digitalboard.core.authentik_outpost_proxy + vars: + authentik_outpost_proxy_host: "https://auth.example.com" + authentik_outpost_proxy_token: "{{ vault_authentik_proxy_outpost_token }}" +``` + +## License + +MIT-0 diff --git a/roles/authentik_outpost_proxy/defaults/main.yml b/roles/authentik_outpost_proxy/defaults/main.yml new file mode 100644 index 0000000..a9ffe06 --- /dev/null +++ b/roles/authentik_outpost_proxy/defaults/main.yml @@ -0,0 +1,28 @@ +#SPDX-License-Identifier: MIT-0 +--- +# defaults file for authentik_outpost_proxy + +# Base directory configuration (inherited from base role or defined here) +docker_compose_base_dir: /etc/docker/compose +docker_volume_base_dir: /srv/data + +# Service configuration +authentik_outpost_proxy_service_name: authentik-outpost-proxy +authentik_outpost_proxy_docker_compose_dir: "{{ docker_compose_base_dir }}/{{ authentik_outpost_proxy_service_name }}" + +# Container image (must match authentik server version) +authentik_outpost_proxy_image: "ghcr.io/goauthentik/proxy:2026.2.2" + +# Connection to authentik server +authentik_outpost_proxy_host: "https://authentik.local.test" +authentik_outpost_proxy_token: "changeme" +authentik_outpost_proxy_insecure: "true" + +# Traefik network the service-side routers live on, so the local +# ForwardAuth middleware can reach this outpost over the docker network. +authentik_outpost_proxy_network: "proxy" + +# Extra hosts for DNS resolution within the container (e.g. pinning the +# authentik FQDN to a backend IP when the public name is unreachable). +authentik_outpost_proxy_extra_hosts: [] +# - "auth.example.com:192.168.56.11" diff --git a/roles/authentik_outpost_proxy/handlers/main.yml b/roles/authentik_outpost_proxy/handlers/main.yml new file mode 100644 index 0000000..53c0099 --- /dev/null +++ b/roles/authentik_outpost_proxy/handlers/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# handlers file for authentik_outpost_proxy diff --git a/roles/authentik_outpost_proxy/meta/main.yml b/roles/authentik_outpost_proxy/meta/main.yml new file mode 100644 index 0000000..122e1ad --- /dev/null +++ b/roles/authentik_outpost_proxy/meta/main.yml @@ -0,0 +1,28 @@ +#SPDX-License-Identifier: MIT-0 +galaxy_info: + author: digitalboard + description: Deploy an authentik proxy (ForwardAuth) outpost via Docker Compose, co-located with the services it protects + company: Digitalboard + license: MIT-0 + + min_ansible_version: "2.14" + + platforms: + - name: Debian + versions: + - bookworm + - name: Ubuntu + versions: + - jammy + - noble + + galaxy_tags: + - authentik + - proxy + - forwardauth + - outpost + - sso + - docker + - digitalboard + +dependencies: [] diff --git a/roles/authentik_outpost_proxy/tasks/main.yml b/roles/authentik_outpost_proxy/tasks/main.yml new file mode 100644 index 0000000..0b1ec00 --- /dev/null +++ b/roles/authentik_outpost_proxy/tasks/main.yml @@ -0,0 +1,26 @@ +#SPDX-License-Identifier: MIT-0 +--- +# tasks file for authentik_outpost_proxy + +- name: Create docker compose directory + file: + path: "{{ authentik_outpost_proxy_docker_compose_dir }}" + state: directory + mode: '0755' + +- name: Create docker-compose file for authentik proxy outpost + template: + src: docker-compose.yml.j2 + dest: "{{ authentik_outpost_proxy_docker_compose_dir }}/docker-compose.yml" + mode: '0644' + +- name: Start authentik proxy outpost container + community.docker.docker_compose_v2: + project_src: "{{ authentik_outpost_proxy_docker_compose_dir }}" + state: present + wait: true + wait_timeout: 120 + retries: 3 + delay: 15 + register: result + until: result is not failed diff --git a/roles/authentik_outpost_proxy/templates/docker-compose.yml.j2 b/roles/authentik_outpost_proxy/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..b1f6b08 --- /dev/null +++ b/roles/authentik_outpost_proxy/templates/docker-compose.yml.j2 @@ -0,0 +1,22 @@ +services: + proxy: + image: {{ authentik_outpost_proxy_image }} + restart: unless-stopped + environment: + AUTHENTIK_HOST: {{ authentik_outpost_proxy_host }} + AUTHENTIK_TOKEN: {{ authentik_outpost_proxy_token }} + AUTHENTIK_INSECURE: "{{ authentik_outpost_proxy_insecure }}" +{% if authentik_outpost_proxy_extra_hosts | length > 0 %} + extra_hosts: +{% for host in authentik_outpost_proxy_extra_hosts %} + - "{{ host }}" +{% endfor %} +{% endif %} + labels: + - traefik.enable=false + networks: + - {{ authentik_outpost_proxy_network }} + +networks: + {{ authentik_outpost_proxy_network }}: + external: true diff --git a/roles/authentik_outpost_proxy/tests/inventory b/roles/authentik_outpost_proxy/tests/inventory new file mode 100644 index 0000000..712db59 --- /dev/null +++ b/roles/authentik_outpost_proxy/tests/inventory @@ -0,0 +1,2 @@ +#SPDX-License-Identifier: MIT-0 +localhost diff --git a/roles/authentik_outpost_proxy/tests/test.yml b/roles/authentik_outpost_proxy/tests/test.yml new file mode 100644 index 0000000..c5e7aed --- /dev/null +++ b/roles/authentik_outpost_proxy/tests/test.yml @@ -0,0 +1,6 @@ +#SPDX-License-Identifier: MIT-0 +--- +- hosts: localhost + remote_user: root + roles: + - authentik_outpost_proxy diff --git a/roles/authentik_outpost_proxy/vars/main.yml b/roles/authentik_outpost_proxy/vars/main.yml new file mode 100644 index 0000000..d9c7ed6 --- /dev/null +++ b/roles/authentik_outpost_proxy/vars/main.yml @@ -0,0 +1,3 @@ +#SPDX-License-Identifier: MIT-0 +--- +# vars file for authentik_outpost_proxy diff --git a/roles/garage/defaults/main.yml b/roles/garage/defaults/main.yml index 3820a03..1b8a1eb 100644 --- a/roles/garage/defaults/main.yml +++ b/roles/garage/defaults/main.yml @@ -20,7 +20,15 @@ garage_image: "dxflrs/garage:v2.1.0" garage_s3_domains: - "storage.local.test" garage_web_domain: "web.storage.local.test" +# Canonical WebUI console hostname (first entry of garage_webui_domains). garage_webui_domain: "console.storage.local.test" +# FQDNs the WebUI console router accepts. The first entry is the canonical +# public domain; further entries cover internal *.int.* names so a DMZ +# reverseproxy can use a backend_host whose cert SAN matches (same pattern +# as garage_s3_domains). Defaults to just the canonical domain so existing +# inventories that only set garage_webui_domain keep working unchanged. +garage_webui_domains: + - "{{ garage_webui_domain }}" # Garage WebUI configuration garage_webui_enabled: true diff --git a/roles/garage/meta/argument_specs.yml b/roles/garage/meta/argument_specs.yml index b5cb0f5..69b04c4 100644 --- a/roles/garage/meta/argument_specs.yml +++ b/roles/garage/meta/argument_specs.yml @@ -45,7 +45,16 @@ argument_specs: garage_webui_domain: type: str default: console.storage.local.test - description: Hostname serving the WebUI console. + description: Canonical WebUI console hostname (first entry of C(garage_webui_domains)). + garage_webui_domains: + type: list + elements: str + default: ['console.storage.local.test'] + description: + - FQDNs the WebUI console router accepts. The first entry is the + canonical public domain; further entries cover internal + C(*.int.*) names so a DMZ reverseproxy can use a C(backend_host) + whose cert SAN matches. Defaults to just C(garage_webui_domain). garage_webui_enabled: type: bool diff --git a/roles/garage/templates/docker-compose.yml.j2 b/roles/garage/templates/docker-compose.yml.j2 index 7b1c017..52b3bad 100644 --- a/roles/garage/templates/docker-compose.yml.j2 +++ b/roles/garage/templates/docker-compose.yml.j2 @@ -49,7 +49,7 @@ services: labels: - traefik.enable=true - traefik.docker.network={{ garage_traefik_network }} - - traefik.http.routers.{{ garage_service_name }}-console.rule=Host(`{{ garage_webui_domain }}`) + - traefik.http.routers.{{ garage_service_name }}-console.rule={% for d in garage_webui_domains %}Host(`{{ d }}`){% if not loop.last %} || {% endif %}{% endfor +%} {% if garage_use_ssl %} - traefik.http.routers.{{ garage_service_name }}-console.entrypoints=websecure - traefik.http.routers.{{ garage_service_name }}-console.tls=true -- 2.49.1 From c4220f2c2d55edfd01294019624fad623187cbad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Thu, 2 Jul 2026 17:11:51 +0200 Subject: [PATCH 2/3] feat(authentik): pin known tokens on proxy outposts too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize the outpost token-pinning path beyond the LDAP outpost so any proxy outpost declaring a token authenticates against the authentik server with the vault token instead of the blueprint's auto-generated one. Drive both from a single rendered script over a target list. Also raise the server healthcheck start_period to 120s: cold first boot runs DB migrations plus app init (~90s observed), which tripped compose --wait as unhealthy at the old 30s. Signed-off-by: Simon Bärlocher --- roles/authentik/tasks/main.yml | 26 ++++++++++----- .../authentik/templates/docker-compose.yml.j2 | 4 ++- .../templates/set-outpost-token.py.j2 | 32 ++++++++++++++----- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/roles/authentik/tasks/main.yml b/roles/authentik/tasks/main.yml index 1471836..1947fc4 100644 --- a/roles/authentik/tasks/main.yml +++ b/roles/authentik/tasks/main.yml @@ -49,22 +49,32 @@ until: blueprint_wait_result.rc == 0 when: blueprints_changed -- name: Render LDAP outpost token script +# Pin known tokens on the LDAP outpost and on any proxy outpost that +# declares one, so co-located outposts (e.g. a proxy outpost on another +# host) can authenticate against the authentik server with the token from +# the vault instead of the auto-generated one the blueprint creates. +- name: Set fact whether any outpost needs a pinned token + ansible.builtin.set_fact: + _authentik_outpost_tokens: >- + {{ (authentik_ldap_outpost.name is defined) + or (authentik_proxy_outposts | default([]) | selectattr('token', 'defined') | list | length > 0) }} + +- name: Render outpost token script template: src: set-outpost-token.py.j2 dest: "{{ authentik_docker_volume_dir }}/data/set-outpost-token.py" mode: '0644' - when: authentik_ldap_outpost.name is defined - register: ldap_token_script + when: _authentik_outpost_tokens | bool + register: outpost_token_script -- name: Set known token for LDAP outpost +- name: Set known tokens for outposts community.docker.docker_compose_v2_exec: project_src: "{{ authentik_docker_compose_dir }}" service: server command: ak shell -c "exec(open('/data/set-outpost-token.py').read())" - register: ldap_token_result - changed_when: "'changed' in ldap_token_result.stdout" + register: outpost_token_result + changed_when: "'changed' in outpost_token_result.stdout" retries: 30 delay: 10 - until: ldap_token_result.rc == 0 - when: authentik_ldap_outpost.name is defined and (blueprints_changed or ldap_token_script.changed) \ No newline at end of file + until: outpost_token_result.rc == 0 + when: (_authentik_outpost_tokens | bool) and (blueprints_changed or outpost_token_script.changed) \ No newline at end of file diff --git a/roles/authentik/templates/docker-compose.yml.j2 b/roles/authentik/templates/docker-compose.yml.j2 index cd3ef1e..ce427f2 100644 --- a/roles/authentik/templates/docker-compose.yml.j2 +++ b/roles/authentik/templates/docker-compose.yml.j2 @@ -23,7 +23,9 @@ services: command: server healthcheck: test: ["CMD", "ak", "healthcheck"] - start_period: 30s + # Cold first boot runs DB migrations + app init (~90s observed); + # start_period must cover that or compose --wait trips unhealthy. + start_period: 120s interval: 10s retries: 5 timeout: 5s diff --git a/roles/authentik/templates/set-outpost-token.py.j2 b/roles/authentik/templates/set-outpost-token.py.j2 index 0b61705..ffaa56b 100644 --- a/roles/authentik/templates/set-outpost-token.py.j2 +++ b/roles/authentik/templates/set-outpost-token.py.j2 @@ -1,10 +1,26 @@ from authentik.outposts.models import Outpost from authentik.core.models import Token -o = Outpost.objects.get(name='{{ authentik_ldap_outpost.name }}') -t = Token.objects.get(identifier=o.token_identifier) -if t.key != '{{ authentik_ldap_outpost.token }}': - t.key = '{{ authentik_ldap_outpost.token }}' - t.save(update_fields=['key']) - print('changed') -else: - print('ok') + +# (outpost name, desired token key) for every outpost we pin a known +# token on: the LDAP outpost plus any proxy outpost that declares a +# `token` (co-located proxy outposts authenticate with it against the +# authentik server). +_targets = [ +{% if authentik_ldap_outpost.name is defined %} + ('{{ authentik_ldap_outpost.name }}', '{{ authentik_ldap_outpost.token }}'), +{% endif %} +{% for o in authentik_proxy_outposts | default([]) if o.token is defined %} + ('{{ o.name }}', '{{ o.token }}'), +{% endfor %} +] + +changed = False +for name, key in _targets: + o = Outpost.objects.get(name=name) + t = Token.objects.get(identifier=o.token_identifier) + if t.key != key: + t.key = key + t.save(update_fields=['key']) + changed = True + +print('changed' if changed else 'ok') -- 2.49.1 From 08e74f5aebb1a1589d2d0efa4dfb383027d9d64a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Thu, 2 Jul 2026 17:12:02 +0200 Subject: [PATCH 3/3] fix(nextcloud): drop obsolete UserConfig::getValueBool patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nextcloud/server#59629 TypeError workaround (PR #59646) is no longer needed once nextcloud_image is >= 33.0.4, which now ships the fix. Remove the in-container sed patch task, its container-discovery and drift-guard tasks, and the README note. Signed-off-by: Simon Bärlocher --- roles/nextcloud/README.md | 5 ---- roles/nextcloud/tasks/main.yml | 55 ---------------------------------- 2 files changed, 60 deletions(-) diff --git a/roles/nextcloud/README.md b/roles/nextcloud/README.md index f4cafa9..5d16488 100644 --- a/roles/nextcloud/README.md +++ b/roles/nextcloud/README.md @@ -14,11 +14,6 @@ backends. 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 (nextcloud/server#59629, fixed in - master via PR #59646 with no stable33 backport before 33.0.4). - Idempotent via grep guard; remove the patch task once - `nextcloud_image` is >= 33.0.4. ## Requirements diff --git a/roles/nextcloud/tasks/main.yml b/roles/nextcloud/tasks/main.yml index c43bc4d..e33088b 100644 --- a/roles/nextcloud/tasks/main.yml +++ b/roles/nextcloud/tasks/main.yml @@ -49,61 +49,6 @@ 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: - # rc 0 -> already patched; rc 1 -> still the unpatched original; rc 2 -> - # neither marker present (upstream drift -> the guard task below fails loud). - cmd: >- - docker exec {{ item }} sh -c ' - grep -q "strtolower((string)\$this->getTypedValue" /var/www/html/lib/private/Config/UserConfig.php && exit 0; - grep -q "strtolower(\$this->getTypedValue" /var/www/html/lib/private/Config/UserConfig.php && exit 1; - exit 2' - loop: "{{ _nextcloud_php_containers.stdout_lines }}" - register: _nextcloud_userconfig_check - changed_when: false - failed_when: false - -- name: Fail if the UserConfig.php source drifted from the expected upstream line - ansible.builtin.fail: - msg: >- - Neither the patched nor the expected original strtolower($this->getTypedValue(...)) - line was found in {{ item.item }}:/var/www/html/lib/private/Config/UserConfig.php. - The nextcloud/server#59629 workaround can no longer locate its target — the upstream - source likely changed. Re-verify whether the fix shipped (then drop this block) or - update the sed expression. Silently skipping would let the TypeError regress. - loop: "{{ _nextcloud_userconfig_check.results }}" - loop_control: - label: "{{ item.item }}" - when: - - item.rc | default(2) == 2 - -- name: Apply UserConfig::getValueBool string-cast workaround - ansible.builtin.shell: - cmd: >- - 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(2) == 1 - - name: Wait for Nextcloud to be ready ansible.builtin.shell: cmd: docker compose exec -T nextcloud php /var/www/html/occ status --output=json -- 2.49.1