From 2942dabc543859ec6418205ba9c6664eb9a5aee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Fri, 5 Jun 2026 13:49:30 +0200 Subject: [PATCH] refactor(demo-gymburgdorf): drop DNS workarounds now backend can reach DMZ The firewall now permits the backend subnet (172.16.19.0/24) to reach the DMZ reverseproxy on 443, so server-to-server calls can use the public FQDN over the reverseproxy instead of the previous workarounds: - Remove the `*_extra_hosts` pins from nextcloud, opnform, bookstack and homarr; their OIDC discovery/issuer now uses the public auth.gymb.* FQDN (verified: discovery returns 200 with the public iss over the reverseproxy). - Switch nextcloud OIDC discovery_url from auth.int.* to auth.gymb.*. - Drop authentik_host_rewrite_domains and authentik_outpost_domains; point the LDAP outpost (both host_vars) at the public auth.gymb.* FQDN. - Repoint the garage-webui ForwardAuth from the never-functional dedicated outpost FQDN to auth.gymb.*. The embedded outpost only serves the auth path when Host is a configured authentik_domain and matches the protected app via X-Forwarded-Host, so auth.gymb.* stays pinned to the application host on storage to bypass the reverseproxy's X-Forwarded-Host stripping. The *.int.* names and their cert SANs are kept: the DMZ verifies the backend cert (acme cert_mode) and connects by name, so they remain structural to the DMZ->backend hop. The console.s3 DMZ route limitation is pre-existing and tracked separately. --- .../host_vars/application/authentik.yml | 24 +++------------ .../application/authentik_outpost_ldap.yml | 5 +++- .../host_vars/application/bookstack.yml | 17 ++++------- .../host_vars/application/homarr.yml | 19 ++++-------- .../host_vars/application/nextcloud.yml | 30 +++++++------------ .../host_vars/application/opnform.yml | 24 ++++----------- .../host_vars/storage/garage.yml | 22 +++++++------- .../host_vars/storage/traefik.yml | 27 ++++++++--------- 8 files changed, 58 insertions(+), 110 deletions(-) diff --git a/inventories/demo-gymburgdorf/host_vars/application/authentik.yml b/inventories/demo-gymburgdorf/host_vars/application/authentik.yml index 62ffa06..bb4d9a0 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/authentik.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/authentik.yml @@ -10,26 +10,9 @@ _authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data authentik_domains: - "auth.gymb.souveredu.ch" -# Internal FQDN for server-to-server calls (Nextcloud OIDC discovery, -# token, userinfo; LDAP outpost configuration pull). Traefik rewrites -# the Host header to `authentik_domains[0]` on these routers so authentik -# still emits issuer URLs against the public hostname — that keeps the -# iss claim matching what the browser sees while the traffic itself -# stays inside the LAN (the DMZ has no hairpin-NAT for the public IP). -authentik_host_rewrite_domains: - - "auth.int.gymb.souveredu.ch" authentik_secret_key: "{{ _authentik.secret_key }}" authentik_postgres_password: "{{ _authentik.postgres_password }}" -# Dedicated FQDN for cross-host ForwardAuth (storage Traefik calling -# /outpost.goauthentik.io/auth/traefik). Routing through the public -# auth.gymb.* FQDN doesn't work — Authentik sees Host: auth.gymb.* and -# routes to ASGI which 404s the outpost path. This FQDN sits outside -# authentik_domains so the same request falls through to the embedded -# outpost handler (which matches the protected app via X-Forwarded-Host). -authentik_outpost_domains: - - "outpost.auth.int.gymb.souveredu.ch" - # LDAP outpost (provider for nextcloud) authentik_ldap_apps: - slug: ldap @@ -41,9 +24,10 @@ authentik_ldap_outpost: name: "ldap-outpost" token: "{{ _authentik.ldap_outpost_token }}" config: - # Outpost pulls config from authentik over the internal FQDN — keeps - # the round-trip in the LAN with a valid cert. - authentik_host: "https://auth.int.gymb.souveredu.ch/" + # Outpost pulls config from authentik over the public FQDN, which + # resolves (internal DNS view) to the DMZ reverseproxy the backend + # subnet can reach — keeps the round-trip in the LAN with a valid cert. + authentik_host: "https://auth.gymb.souveredu.ch/" log_level: "info" # Proxy providers (ForwardAuth) — gate downstream services behind diff --git a/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml b/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml index 562f979..fa81a01 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml @@ -3,5 +3,8 @@ # authenticate against the authentik server it talks to. _authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" -authentik_outpost_ldap_host: "https://auth.int.gymb.souveredu.ch" +# Public FQDN resolves (internal DNS view) to the DMZ reverseproxy the +# backend subnet can reach, so the outpost->authentik round-trip stays in +# the LAN with a valid cert and matches the iss claim authentik emits. +authentik_outpost_ldap_host: "https://auth.gymb.souveredu.ch" authentik_outpost_ldap_token: "{{ _authentik.ldap_outpost_token }}" diff --git a/inventories/demo-gymburgdorf/host_vars/application/bookstack.yml b/inventories/demo-gymburgdorf/host_vars/application/bookstack.yml index 1d0beac..0185c11 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/bookstack.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/bookstack.yml @@ -20,11 +20,11 @@ bookstack_admin_email: "admin@gymb.souveredu.ch" bookstack_admin_name: "BookStack Admin" # OIDC against Authentik. BookStack compares OIDC_ISSUER strictly against -# the `iss` claim in the discovery response. Authentik emits the public -# auth.gymb.* hostname there (host-rewrite middleware ensures the claim -# matches what browsers see during login), so the issuer URL must use the -# public FQDN. Pinning auth.gymb.* in /etc/hosts below keeps the actual -# server-to-server traffic on the LAN. +# the `iss` claim in the discovery response, and Authentik emits the +# public auth.gymb.* hostname there, so the issuer must use the public +# FQDN. That FQDN resolves (internal DNS view) to the DMZ reverseproxy, +# which the backend subnet can reach, so the server-to-server calls stay +# in the LAN. bookstack_oidc_enabled: true bookstack_oidc_name: "Authentik" bookstack_oidc_issuer: "https://auth.gymb.souveredu.ch/application/o/bookstack/" @@ -34,10 +34,3 @@ bookstack_oidc_additional_scopes: "openid profile email" bookstack_oidc_user_to_groups: true bookstack_oidc_groups_claim: "groups" bookstack_oidc_auto_initiate: false - -# Pin auth.gymb.* to the application host so server-to-server OIDC calls -# (discovery, token, userinfo, jwks) stay in the LAN and reach authentik -# directly without hairpinning through the DMZ (which has no NAT loop -# back to its own public IP). -bookstack_extra_hosts: - - "auth.gymb.souveredu.ch:172.16.19.101" diff --git a/inventories/demo-gymburgdorf/host_vars/application/homarr.yml b/inventories/demo-gymburgdorf/host_vars/application/homarr.yml index 96b2b7d..21e9e36 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/homarr.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/homarr.yml @@ -18,12 +18,11 @@ homarr_admin_password: "{{ _homarr.admin_password }}" # AUTH_OIDC_AUTO_LOGIN bypasses the normal /login page. # # Issuer must match the `iss` claim authentik emits, which is always the -# public FQDN (authentik's host-rewrite middleware aligns the claim with -# what browsers see). Homarr (oauth4webapi) does a strict 1:1 comparison -# between the discovery response's issuer and this URL — using the -# internal FQDN here fails with OAUTH_JSON_ATTRIBUTE_COMPARISON_FAILED. -# The extra_hosts pin below keeps the actual discovery/token/userinfo -# traffic on the LAN. +# public FQDN. Homarr (oauth4webapi) does a strict 1:1 comparison between +# the discovery response's issuer and this URL — an internal FQDN here +# fails with OAUTH_JSON_ATTRIBUTE_COMPARISON_FAILED. The public FQDN +# resolves (internal DNS view) to the DMZ reverseproxy, which the backend +# subnet can reach, so discovery/token/userinfo stay in the LAN. homarr_auth_providers: "credentials,oidc" homarr_oidc_issuer: "https://auth.gymb.souveredu.ch/application/o/homarr/" homarr_oidc_client_id: "homarr" @@ -33,14 +32,6 @@ homarr_oidc_scopes: "openid profile email groups" homarr_oidc_groups_attribute: "groups" homarr_oidc_auto_login: "true" -# Pin the public authentik FQDN to the application host so OIDC -# discovery (and downstream token/userinfo) calls from the homarr -# container stay in the LAN. Without this, fetch() to auth.gymb.* would -# hit the public IP and time out in the DMZ (no hairpin-NAT). Same -# pattern as nextcloud_extra_hosts. -homarr_extra_hosts: - - "auth.gymb.souveredu.ch:172.16.19.101" - # Default board with shortcuts to the other gymburgdorf services. Width # values describe horizontal grid cells (1-10 desktop / 6 tablet / 2 # mobile, packed left-to-right). diff --git a/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml b/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml index b30a0f9..c82c487 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml @@ -62,25 +62,16 @@ nextcloud_s3_port: 443 nextcloud_s3_ssl: true nextcloud_s3_usepath_style: true -# OIDC server-to-server discovery / token / userinfo goes to -# auth.int.gymb.souveredu.ch (LAN, RFC1918). Nextcloud's DnsPinMiddleware -# would otherwise block that as "local server access". +# OIDC discovery/token/userinfo and the S3 backend both target FQDNs +# that resolve to RFC1918 addresses (auth.gymb.* via the reverseproxy, +# s3.int.* directly), which Nextcloud's DnsPinMiddleware would otherwise +# block as "local server access". nextcloud_allow_local_remote_servers: true # Share the LDAP docker network with the authentik LDAP outpost nextcloud_extra_networks: - ldap -# Pin the public authentik FQDN to the application host so server-to-server -# OIDC traffic (token, userinfo, jwks — endpoints the discovery doc lists -# under auth.gymb.* even when discovery itself is fetched via auth.int.*) -# stays in the LAN. Without this, curl in the PHP container would hit the -# public IP and time out in the DMZ (no hairpin-NAT). The DnsPin middleware -# only honours /etc/hosts when allow_local_remote_servers is enabled, so -# that flag (set above) is what makes this entry effective. -nextcloud_extra_hosts: - - "auth.gymb.souveredu.ch:172.16.19.101" - # LDAP backend (Authentik LDAP outpost) nextcloud_ldap_enabled: true nextcloud_ldap_config: @@ -118,13 +109,12 @@ nextcloud_oidc_providers: display_name: "Login with Authentik" client_id: nextcloud client_secret: "{{ _authentik.nextcloud_oidc_secret }}" - # Discovery via the internal FQDN (LAN-only) — the DMZ has no - # hairpin-NAT for the public IP, so server-to-server calls to - # auth.gymb.* would time out. The traefik router for auth.int.* - # rewrites the Host header to auth.gymb.souveredu.ch before the - # request reaches authentik, so the iss claim authentik emits still - # matches the public hostname the browser sees during login. - discovery_url: "https://auth.int.gymb.souveredu.ch/application/o/nextcloud/.well-known/openid-configuration" + # Discovery via the public FQDN. It resolves (internal DNS view) to + # the DMZ reverseproxy, which the backend subnet can now reach, so + # the server-to-server call stays in the LAN while the iss claim and + # all listed endpoints match the public hostname the browser sees + # during login — no host-rewrite or /etc/hosts pin needed. + discovery_url: "https://auth.gymb.souveredu.ch/application/o/nextcloud/.well-known/openid-configuration" scope: "openid email profile" unique_uid: true mapping: diff --git a/inventories/demo-gymburgdorf/host_vars/application/opnform.yml b/inventories/demo-gymburgdorf/host_vars/application/opnform.yml index 6bf41dc..028e98f 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/opnform.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/opnform.yml @@ -23,18 +23,13 @@ opnform_admin_name: "OpnForm Admin" opnform_admin_email: "admin@gymb.souveredu.ch" opnform_admin_password: "{{ _opnform.admin_password }}" -# OIDC against Authentik. Discovery via the internal FQDN keeps -# server-to-server traffic in the LAN; Authentik's host-rewrite router -# rewrites the Host header to auth.gymb.* before the request reaches -# authentik so the iss claim still matches the public hostname browsers -# see during login. +# OIDC against Authentik. The public auth.gymb.* FQDN resolves (internal +# DNS view) to the DMZ reverseproxy, which the backend subnet can reach, +# so OpnForm's discovery/token/userinfo calls stay in the LAN while the +# iss claim matches the public hostname browsers see during login. opnform_oidc_enabled: true -# Issuer must use the public auth.gymb.* FQDN: OpnForm does OIDC -# discovery and then validates the token's `iss` claim against this -# value. Authentik emits the public hostname in `iss` (its host-rewrite -# middleware keeps the claim aligned with what browsers see), so an -# internal-FQDN issuer here would fail iss validation. The extra_hosts -# pin below keeps the actual discovery/token/userinfo traffic on the LAN. +# Issuer must use the public FQDN: OpnForm validates the token's `iss` +# claim against this value, and Authentik emits the public hostname there. opnform_oidc_issuer: "https://auth.gymb.souveredu.ch/application/o/opnform/" opnform_oidc_client_id: "opnform" opnform_oidc_client_secret: "{{ _opnform.oidc_client_secret }}" @@ -53,10 +48,3 @@ opnform_oidc_force_login: true # Break-glass: /login?bypass=1 reaches the email form when the IdP is # down. opnform_oidc_sso_entrypoint: true - -# Pin auth.gymb.* to the application host so server-to-server OIDC -# calls (token, userinfo, jwks — endpoints discovery returns under the -# public hostname even when discovery itself is fetched via auth.int.*) -# stay in the LAN. -opnform_extra_hosts: - - "auth.gymb.souveredu.ch:172.16.19.101" diff --git a/inventories/demo-gymburgdorf/host_vars/storage/garage.yml b/inventories/demo-gymburgdorf/host_vars/storage/garage.yml index ae23880..c868053 100644 --- a/inventories/demo-gymburgdorf/host_vars/storage/garage.yml +++ b/inventories/demo-gymburgdorf/host_vars/storage/garage.yml @@ -14,17 +14,19 @@ garage_use_ssl: true garage_webui_enabled: true # Gate the WebUI behind authentik (admins-only, via policy-binding on the # authentik proxy app). Replaces the htpasswd Basic-Auth — AUTH_USER_PASS -# is dropped from the compose env when this is true. The forwardauth URL -# uses a dedicated outpost-only FQDN that's deliberately outside -# authentik_domains so Authentik routes it to the embedded outpost (not -# ASGI). The public auth.gymb.* FQDN would 404 here — Authentik routes -# any Host matching an auth-domain to ASGI which doesn't serve the outpost -# path. The outpost itself then matches the protected app via -# X-Forwarded-Host (Traefik forwards it via trustForwardHeader=true). -# The FQDN is pinned to the application host via traefik_extra_hosts so -# the request stays in the LAN. +# is dropped from the compose env when this is true. +# +# The embedded outpost only serves the /outpost.goauthentik.io/auth path +# when the request reaches authentik with Host set to a configured +# authentik_domain (auth.gymb.*); it then matches the protected app +# (console.s3.*) via X-Forwarded-Host. So the ForwardAuth must hit the +# app-host's `authentik` router (Host(auth.gymb), passHostHeader=true) — +# NOT the DMZ reverseproxy (strips X-Forwarded-Host) and NOT the auth.int +# rewrite router (overwrites X-Forwarded-Host). auth.gymb.* is therefore +# pinned to the application host in storage/traefik.yml so this single hop +# preserves the forwarded headers and stays in the LAN. garage_webui_authentik_forward_auth: true -garage_webui_authentik_forward_auth_url: "https://outpost.auth.int.gymb.souveredu.ch/outpost.goauthentik.io/auth/traefik" +garage_webui_authentik_forward_auth_url: "https://auth.gymb.souveredu.ch/outpost.goauthentik.io/auth/traefik" # Kept for completeness — only used when authentik ForwardAuth is off. garage_webui_username: "admin" garage_webui_password: "{{ _garage.webui_password | default('disabled') }}" diff --git a/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml b/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml index 36530ef..f33c1df 100644 --- a/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml +++ b/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml @@ -1,18 +1,12 @@ --- -# Local traefik needs to reach authentik for the ForwardAuth subrequest -# the garage-webui router fires. The public IP is unreachable from this -# subnet (no DMZ hairpin), so pin both auth FQDNs directly at the -# application host where authentik runs. Without this the forwardauth -# middleware would time out and every garage-console request would 502. -# - auth.gymb.* covers any future server-to-server traffic on the public -# FQDN. -# - outpost.auth.int.gymb.* is the dedicated outpost endpoint actually -# used by the ForwardAuth middleware (see garage.yml). It exists only -# to skip Authentik's ASGI handler, which 404s the outpost path when -# Host is one of the configured authentik_domains. +# The garage-webui ForwardAuth middleware fires a subrequest to authentik +# (see garage.yml). It must reach the app-host's `authentik` router +# directly — going via the DMZ reverseproxy strips X-Forwarded-Host, which +# breaks the embedded outpost's app matching. The internal DNS view points +# auth.gymb.* at the reverseproxy, so pin it to the application host here +# to force the single direct hop that preserves the forwarded headers. traefik_extra_hosts: - "auth.gymb.souveredu.ch:172.16.19.101" - - "outpost.auth.int.gymb.souveredu.ch:172.16.19.101" # Services hosted on `storage` that the DMZ reverseproxy should forward # public traffic to. See application/traefik.yml for the mechanism. @@ -24,8 +18,11 @@ traefik_dmz_exposed_services: protocol: https - name: garage-webui domain: console.s3.gymb.souveredu.ch - # No internal FQDN/cert SAN for console.s3 yet — would need an - # extra_domain on garage-webui. Until then this route will 500 - # against the storage backend (cert mismatch on raw IP). + # Pre-existing limitation, orthogonal to the DNS cleanup: the DMZ + # verifies the storage backend cert (acme cert_mode), but with no + # backend_host set it connects by IP, which the storage cert has no + # SAN for, so this route fails the TLS verify. Fixing it needs the + # garage role to expose a console extra_domain (e.g. console.s3.int.*) + # for the backend_host + cert SAN — tracked separately. port: 443 protocol: https