Compare commits

...

3 commits

Author SHA1 Message Date
Simon Bärlocher
08e74f5aeb
fix(nextcloud): drop obsolete UserConfig::getValueBool patch
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 <simon@whatwedo.ch>
2026-07-02 17:12:02 +02:00
Simon Bärlocher
c4220f2c2d
feat(authentik): pin known tokens on proxy outposts too
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 <simon@whatwedo.ch>
2026-07-02 17:11:51 +02:00
Simon Bärlocher
0733d5710f
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).
2026-06-05 14:22:36 +02:00
17 changed files with 240 additions and 79 deletions

View file

@ -49,22 +49,32 @@
until: blueprint_wait_result.rc == 0 until: blueprint_wait_result.rc == 0
when: blueprints_changed 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: template:
src: set-outpost-token.py.j2 src: set-outpost-token.py.j2
dest: "{{ authentik_docker_volume_dir }}/data/set-outpost-token.py" dest: "{{ authentik_docker_volume_dir }}/data/set-outpost-token.py"
mode: '0644' mode: '0644'
when: authentik_ldap_outpost.name is defined when: _authentik_outpost_tokens | bool
register: ldap_token_script register: outpost_token_script
- name: Set known token for LDAP outpost - name: Set known tokens for outposts
community.docker.docker_compose_v2_exec: community.docker.docker_compose_v2_exec:
project_src: "{{ authentik_docker_compose_dir }}" project_src: "{{ authentik_docker_compose_dir }}"
service: server service: server
command: ak shell -c "exec(open('/data/set-outpost-token.py').read())" command: ak shell -c "exec(open('/data/set-outpost-token.py').read())"
register: ldap_token_result register: outpost_token_result
changed_when: "'changed' in ldap_token_result.stdout" changed_when: "'changed' in outpost_token_result.stdout"
retries: 30 retries: 30
delay: 10 delay: 10
until: ldap_token_result.rc == 0 until: outpost_token_result.rc == 0
when: authentik_ldap_outpost.name is defined and (blueprints_changed or ldap_token_script.changed) when: (_authentik_outpost_tokens | bool) and (blueprints_changed or outpost_token_script.changed)

View file

@ -23,7 +23,9 @@ services:
command: server command: server
healthcheck: healthcheck:
test: ["CMD", "ak", "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 interval: 10s
retries: 5 retries: 5
timeout: 5s timeout: 5s

View file

@ -1,10 +1,26 @@
from authentik.outposts.models import Outpost from authentik.outposts.models import Outpost
from authentik.core.models import Token from authentik.core.models import Token
o = Outpost.objects.get(name='{{ authentik_ldap_outpost.name }}')
t = Token.objects.get(identifier=o.token_identifier) # (outpost name, desired token key) for every outpost we pin a known
if t.key != '{{ authentik_ldap_outpost.token }}': # token on: the LDAP outpost plus any proxy outpost that declares a
t.key = '{{ authentik_ldap_outpost.token }}' # `token` (co-located proxy outposts authenticate with it against the
t.save(update_fields=['key']) # authentik server).
print('changed') _targets = [
else: {% if authentik_ldap_outpost.name is defined %}
print('ok') ('{{ 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')

View file

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

View file

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

View file

@ -0,0 +1,3 @@
#SPDX-License-Identifier: MIT-0
---
# handlers file for authentik_outpost_proxy

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
#SPDX-License-Identifier: MIT-0
localhost

View file

@ -0,0 +1,6 @@
#SPDX-License-Identifier: MIT-0
---
- hosts: localhost
remote_user: root
roles:
- authentik_outpost_proxy

View file

@ -0,0 +1,3 @@
#SPDX-License-Identifier: MIT-0
---
# vars file for authentik_outpost_proxy

View file

@ -20,7 +20,15 @@ garage_image: "dxflrs/garage:v2.1.0"
garage_s3_domains: garage_s3_domains:
- "storage.local.test" - "storage.local.test"
garage_web_domain: "web.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" 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 configuration
garage_webui_enabled: true garage_webui_enabled: true

View file

@ -45,7 +45,16 @@ argument_specs:
garage_webui_domain: garage_webui_domain:
type: str type: str
default: console.storage.local.test 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: garage_webui_enabled:
type: bool type: bool

View file

@ -49,7 +49,7 @@ services:
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.docker.network={{ garage_traefik_network }} - 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 %} {% if garage_use_ssl %}
- traefik.http.routers.{{ garage_service_name }}-console.entrypoints=websecure - traefik.http.routers.{{ garage_service_name }}-console.entrypoints=websecure
- traefik.http.routers.{{ garage_service_name }}-console.tls=true - traefik.http.routers.{{ garage_service_name }}-console.tls=true

View file

@ -14,11 +14,6 @@ backends.
LDAP via `occ` — every setting is read first and only written when LDAP via `occ` — every setting is read first and only written when
the stored value differs, so re-runs don't churn the stored value differs, so re-runs don't churn
- Sets up notify_push (when enabled) - 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 ## Requirements

View file

@ -49,61 +49,6 @@
project_src: "{{ nextcloud_docker_compose_dir }}" project_src: "{{ nextcloud_docker_compose_dir }}"
state: present 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 - name: Wait for Nextcloud to be ready
ansible.builtin.shell: ansible.builtin.shell:
cmd: docker compose exec -T nextcloud php /var/www/html/occ status --output=json cmd: docker compose exec -T nextcloud php /var/www/html/occ status --output=json