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 1/5] 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 -- 2.49.1 From e32c2202eaa27be8fb8a56da95262e3218f4eab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Fri, 5 Jun 2026 14:27:37 +0200 Subject: [PATCH 2/5] feat(demo-gymburgdorf): gate garage-webui via storage-local proxy outpost Finish the garage-webui SSO that the DNS cleanup left broken. Two coupled fixes, both relying on roles added in digitalboard.core: - console.s3 now has an internal name. garage_webui_domains lists both the public and console.s3.int.* FQDN, and the DMZ garage-webui route sets backend_host: console.s3.int.* so the DMZ->storage hop verifies the backend cert against a matching SAN instead of failing on a raw IP (500). - garage-webui ForwardAuth moves off the embedded outpost (cross-host hop mangled X-Forwarded-Host -> 404) onto a dedicated proxy outpost deployed on storage (authentik_outpost_proxy). The garage Traefik middleware now talks to it over the local docker network, and the storage-proxy-outpost is registered in authentik with the garage-webui provider. The stale auth.gymb pin on storage is dropped; the outpost reaches authentik via the reverseproxy (firewall now permits backend -> DMZ). Requires a new bao secret key: /data/authentik proxy_outpost_token. --- .../host_vars/application/authentik.yml | 20 +++++++++++---- .../storage/authentik_outpost_proxy.yml | 16 ++++++++++++ .../host_vars/storage/garage.yml | 25 +++++++++++-------- .../host_vars/storage/traefik.yml | 23 ++++++----------- inventories/demo-gymburgdorf/hosts.yml | 8 ++++++ playbooks/site.yml | 6 +++++ 6 files changed, 67 insertions(+), 31 deletions(-) create mode 100644 inventories/demo-gymburgdorf/host_vars/storage/authentik_outpost_proxy.yml diff --git a/inventories/demo-gymburgdorf/host_vars/application/authentik.yml b/inventories/demo-gymburgdorf/host_vars/application/authentik.yml index bb4d9a0..d5a354f 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/authentik.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/authentik.yml @@ -1,7 +1,7 @@ --- # Bao secret expected at /data/authentik with keys: # secret_key, postgres_password, admin_password, -# ldap_outpost_token, +# ldap_outpost_token, proxy_outpost_token, # nextcloud_oidc_secret, # opnform_oidc_secret, homarr_oidc_secret, bookstack_oidc_secret _authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" @@ -63,15 +63,25 @@ authentik_proxy_apps: authorization_slug: default-provider-authorization-implicit-consent invalidation_slug: default-provider-invalidation-flow -# Bind both proxy providers to authentik's built-in embedded outpost so -# we don't have to deploy a separate proxy outpost container. The -# embedded outpost listens on the same host:9000 as the authentik server -# and exposes /outpost.goauthentik.io/auth/traefik for ForwardAuth. +# Outpost bindings. drawio runs on this (application) host, so its +# ForwardAuth can use the embedded outpost (same host:9000 as the +# authentik server). garage-webui runs on the storage host; a cross-host +# ForwardAuth to the embedded outpost arrives with a mangled +# X-Forwarded-Host (an extra reverseproxy hop appends :443 / a comma list) +# and the outpost then fails to match the provider's external_host (404). +# So garage-webui is bound to a dedicated proxy outpost deployed on +# storage (role digitalboard.core.authentik_outpost_proxy), whose +# ForwardAuth endpoint the local storage Traefik reaches over the docker +# network — no extra hop, clean X-Forwarded-Host. authentik_proxy_outposts: - name: "authentik Embedded Outpost" type: proxy providers: - drawio + - name: "storage-proxy-outpost" + type: proxy + token: "{{ _authentik.proxy_outpost_token }}" + providers: - garage-webui # OIDC clients diff --git a/inventories/demo-gymburgdorf/host_vars/storage/authentik_outpost_proxy.yml b/inventories/demo-gymburgdorf/host_vars/storage/authentik_outpost_proxy.yml new file mode 100644 index 0000000..74a3209 --- /dev/null +++ b/inventories/demo-gymburgdorf/host_vars/storage/authentik_outpost_proxy.yml @@ -0,0 +1,16 @@ +--- +# Proxy (ForwardAuth) outpost co-located with garage on storage. The +# garage-webui Traefik router's ForwardAuth middleware points at this +# container over the local `proxy` docker network, so the auth subrequest +# never leaves storage — the embedded outpost on the application host +# can't be used here because the cross-host hop mangles X-Forwarded-Host. +_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" + +# Outpost pulls its config from authentik over the public FQDN (resolves +# via the internal DNS view to the DMZ reverseproxy the storage subnet can +# reach). Token must match the storage-proxy-outpost registered in +# authentik (see application/authentik.yml authentik_proxy_outposts). +authentik_outpost_proxy_host: "https://auth.gymb.souveredu.ch" +authentik_outpost_proxy_token: "{{ _authentik.proxy_outpost_token }}" +authentik_outpost_proxy_insecure: "true" +authentik_outpost_proxy_network: "proxy" diff --git a/inventories/demo-gymburgdorf/host_vars/storage/garage.yml b/inventories/demo-gymburgdorf/host_vars/storage/garage.yml index c868053..aa0b8f1 100644 --- a/inventories/demo-gymburgdorf/host_vars/storage/garage.yml +++ b/inventories/demo-gymburgdorf/host_vars/storage/garage.yml @@ -9,24 +9,27 @@ _garage: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/ga garage_s3_domains: - "s3.gymb.souveredu.ch" - "s3.int.gymb.souveredu.ch" -garage_webui_domain: "console.s3.gymb.souveredu.ch" +# First entry is the canonical public console FQDN. The *.int.* entry +# gives the DMZ a backend_host whose cert SAN matches (storage/traefik.yml), +# so the DMZ->storage hop verifies TLS instead of failing on a raw IP. +garage_webui_domains: + - "console.s3.gymb.souveredu.ch" + - "console.s3.int.gymb.souveredu.ch" 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 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. +# ForwardAuth talks to the storage-local proxy outpost over the docker +# network (role digitalboard.core.authentik_outpost_proxy). The embedded +# outpost on the application host can't be used cross-host: the extra +# reverseproxy hop mangles X-Forwarded-Host (appends :443 / a comma list) +# and the outpost then 404s instead of matching console.s3.* against the +# provider's external_host. A co-located outpost keeps the subrequest on +# the local network with a clean X-Forwarded-Host. garage_webui_authentik_forward_auth: true -garage_webui_authentik_forward_auth_url: "https://auth.gymb.souveredu.ch/outpost.goauthentik.io/auth/traefik" +garage_webui_authentik_forward_auth_url: "http://authentik-outpost-proxy-proxy-1:9000/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 f33c1df..6c60ffc 100644 --- a/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml +++ b/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml @@ -1,15 +1,10 @@ --- -# 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" - # Services hosted on `storage` that the DMZ reverseproxy should forward # public traffic to. See application/traefik.yml for the mechanism. +# The garage-webui ForwardAuth no longer needs an auth FQDN pinned here — +# it talks to the storage-local proxy outpost over the docker network +# (see garage.yml), and the outpost reaches authentik via the public FQDN +# over the reverseproxy (firewall now permits backend -> DMZ). traefik_dmz_exposed_services: - name: garage-s3 domain: s3.gymb.souveredu.ch @@ -18,11 +13,9 @@ traefik_dmz_exposed_services: protocol: https - name: garage-webui domain: console.s3.gymb.souveredu.ch - # 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. + # Internal name so the DMZ verifies the storage backend cert against a + # matching SAN (acme cert_mode, no insecureSkipVerify). garage now + # serves this SAN via garage_webui_domains (see garage.yml). + backend_host: console.s3.int.gymb.souveredu.ch port: 443 protocol: https diff --git a/inventories/demo-gymburgdorf/hosts.yml b/inventories/demo-gymburgdorf/hosts.yml index 66261bc..4e4f5c2 100644 --- a/inventories/demo-gymburgdorf/hosts.yml +++ b/inventories/demo-gymburgdorf/hosts.yml @@ -48,6 +48,14 @@ all: hosts: application: + # Proxy (ForwardAuth) outpost co-located with garage on storage, so + # the garage-webui ForwardAuth subrequest stays on the local docker + # network instead of crossing an extra reverseproxy hop (which mangles + # X-Forwarded-Host and breaks the embedded outpost's app matching). + authentik_outpost_proxy_servers: + hosts: + storage: + send_servers: hosts: application: diff --git a/playbooks/site.yml b/playbooks/site.yml index 2e70255..1676774 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -70,6 +70,12 @@ roles: - digitalboard.core.authentik_outpost_ldap +- name: Deploy authentik proxy outpost + hosts: authentik_outpost_proxy_servers + become: yes + roles: + - digitalboard.core.authentik_outpost_proxy + - name: Deploy nextcloud service hosts: nextcloud_servers become: yes -- 2.49.1 From 91d5be8d21cb04d71b7e5d78911cb30a326487ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Fri, 5 Jun 2026 14:29:18 +0200 Subject: [PATCH 3/5] fix(demo-gymburgdorf): verify authentik TLS chain from outposts Both outposts reach authentik over auth.gymb.*, which presents a valid Let's Encrypt cert via the reverseproxy (verified: ssl_verify_result=0 from the storage subnet). Drop the insecure TLS skip: - proxy outpost: authentik_outpost_proxy_insecure -> "false" - ldap outpost: pin authentik_outpost_ldap_insecure "false" instead of relying on the role default ("true"). Addresses the automated security review finding (TLS verification disabled). --- .../host_vars/application/authentik_outpost_ldap.yml | 3 +++ .../host_vars/storage/authentik_outpost_proxy.yml | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) 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 fa81a01..b280ce4 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml @@ -8,3 +8,6 @@ _authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data # 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 }}" +# auth.gymb.* presents a valid Let's Encrypt cert via the reverseproxy, so +# verify the chain instead of relying on the role's insecure default. +authentik_outpost_ldap_insecure: "false" diff --git a/inventories/demo-gymburgdorf/host_vars/storage/authentik_outpost_proxy.yml b/inventories/demo-gymburgdorf/host_vars/storage/authentik_outpost_proxy.yml index 74a3209..df7b628 100644 --- a/inventories/demo-gymburgdorf/host_vars/storage/authentik_outpost_proxy.yml +++ b/inventories/demo-gymburgdorf/host_vars/storage/authentik_outpost_proxy.yml @@ -10,7 +10,10 @@ _authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data # via the internal DNS view to the DMZ reverseproxy the storage subnet can # reach). Token must match the storage-proxy-outpost registered in # authentik (see application/authentik.yml authentik_proxy_outposts). +# Verify the authentik TLS chain: auth.gymb.* presents a valid Let's +# Encrypt cert via the reverseproxy and the storage subnet reaches it, so +# there's no reason to disable verification (verified: ssl_verify_result=0). authentik_outpost_proxy_host: "https://auth.gymb.souveredu.ch" authentik_outpost_proxy_token: "{{ _authentik.proxy_outpost_token }}" -authentik_outpost_proxy_insecure: "true" +authentik_outpost_proxy_insecure: "false" authentik_outpost_proxy_network: "proxy" -- 2.49.1 From a8c8ac3e1e298fd7844075ab33c29ee67e320dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Thu, 2 Jul 2026 17:22:08 +0200 Subject: [PATCH 4/5] fix(bao-seed): seed proxy_outpost_token, garage and nextcloud secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed script only generated authentik LDAP, opnform, homarr and bookstack secrets. Proxy outposts, garage and nextcloud were never seeded, so a fresh demo mount was missing garage/*, nextcloud/* and authentik/proxy_outpost_token — the proxy outpost then failed to authenticate (403) and garage/nextcloud roles aborted on missing vault keys. Add the three blocks; merge semantics keep existing keys. --- scripts/bao-seed.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/bao-seed.sh b/scripts/bao-seed.sh index 80945b7..dc59711 100755 --- a/scripts/bao-seed.sh +++ b/scripts/bao-seed.sh @@ -154,12 +154,27 @@ ensure_key authentik secret_key gen_hex64 ensure_key authentik postgres_password gen_pass ensure_key authentik admin_password gen_pass ensure_key authentik ldap_outpost_token gen_hex32 +ensure_key authentik proxy_outpost_token gen_hex32 ensure_key authentik nextcloud_oidc_secret gen_hex32 ensure_key authentik opnform_oidc_secret gen_hex32 ensure_key authentik homarr_oidc_secret gen_hex32 ensure_key authentik bookstack_oidc_secret gen_hex32 write_secret authentik +echo "-> nextcloud" +read_secret nextcloud +ensure_key nextcloud postgres_password gen_long_pass +ensure_key nextcloud admin_password gen_pass +write_secret nextcloud + +echo "-> garage" +read_secret garage +ensure_key garage rpc_secret gen_hex32 +ensure_key garage admin_token gen_hex32 +ensure_key garage metrics_token gen_hex32 +ensure_key garage webui_password gen_pass +write_secret garage + echo "-> opnform" read_secret opnform ensure_key opnform app_key gen_app_key -- 2.49.1 From d45e358efcb2dd0f50db8a8ba5ee070eb8a889fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=A4rlocher?= Date: Thu, 2 Jul 2026 17:22:21 +0200 Subject: [PATCH 5/5] =?UTF-8?q?feat(demo):=20add=20full=20inventories=20fo?= =?UTF-8?q?r=20mbaz=C3=BCrich=20and=20phbern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both demo sites had only a hosts.yml, so every role ran on its defaults (authentik.local.test etc.) and nothing was reachable under the real FQDN. Add the complete group_vars + host_vars analogous to gymburgdorf, with the mbaz.souveredu.ch / phbe.souveredu.ch domain bases, matching Bao mounts, DMZ split-horizon (public + *.int.*), authentik OIDC/LDAP/proxy outposts, nextcloud with S3+LDAP+OIDC, collabora, drawio, garage, send, opnform, homarr and bookstack. authentik_domains lists the *.int.* name too so Traefik requests a cert the DMZ can verify; the storage proxy outpost carries a config block (required by the outpost blueprint serializer). --- .../demo-mbazürich/group_vars/all/ansible.yml | 2 + .../demo-mbazürich/group_vars/all/docker.yml | 1 + .../demo-mbazürich/group_vars/all/vault.yml | 2 + .../group_vars/backend_servers/traefik.yml | 1 + .../group_vars/traefik_servers/traefik.yml | 19 ++ .../host_vars/application/authentik.yml | 167 ++++++++++++++++++ .../application/authentik_outpost_ldap.yml | 13 ++ .../host_vars/application/bookstack.yml | 36 ++++ .../host_vars/application/collabora.yml | 16 ++ .../host_vars/application/drawio.yml | 20 +++ .../host_vars/application/homarr.yml | 78 ++++++++ .../host_vars/application/main.yml | 3 + .../host_vars/application/nextcloud.yml | 122 +++++++++++++ .../host_vars/application/opnform.yml | 50 ++++++ .../host_vars/application/send.yml | 8 + .../host_vars/application/traefik.yml | 46 +++++ .../host_vars/reverseproxy/traefik.yml | 22 +++ .../storage/authentik_outpost_proxy.yml | 19 ++ .../host_vars/storage/garage.yml | 51 ++++++ .../demo-mbazürich/host_vars/storage/main.yml | 2 + .../host_vars/storage/traefik.yml | 21 +++ inventories/demo-mbazürich/hosts.yml | 36 +++- .../demo-phbern/group_vars/all/ansible.yml | 2 + .../demo-phbern/group_vars/all/docker.yml | 1 + .../demo-phbern/group_vars/all/vault.yml | 2 + .../group_vars/backend_servers/traefik.yml | 1 + .../group_vars/traefik_servers/traefik.yml | 19 ++ .../host_vars/application/authentik.yml | 167 ++++++++++++++++++ .../application/authentik_outpost_ldap.yml | 13 ++ .../host_vars/application/bookstack.yml | 36 ++++ .../host_vars/application/collabora.yml | 16 ++ .../host_vars/application/drawio.yml | 20 +++ .../host_vars/application/homarr.yml | 78 ++++++++ .../host_vars/application/main.yml | 3 + .../host_vars/application/nextcloud.yml | 121 +++++++++++++ .../host_vars/application/opnform.yml | 50 ++++++ .../host_vars/application/send.yml | 8 + .../host_vars/application/traefik.yml | 46 +++++ .../host_vars/reverseproxy/traefik.yml | 22 +++ .../storage/authentik_outpost_proxy.yml | 19 ++ .../demo-phbern/host_vars/storage/garage.yml | 51 ++++++ .../demo-phbern/host_vars/storage/main.yml | 2 + .../demo-phbern/host_vars/storage/traefik.yml | 21 +++ inventories/demo-phbern/hosts.yml | 35 +++- 44 files changed, 1458 insertions(+), 10 deletions(-) create mode 100644 inventories/demo-mbazürich/group_vars/all/ansible.yml create mode 100644 inventories/demo-mbazürich/group_vars/all/docker.yml create mode 100644 inventories/demo-mbazürich/group_vars/all/vault.yml create mode 100644 inventories/demo-mbazürich/group_vars/backend_servers/traefik.yml create mode 100644 inventories/demo-mbazürich/group_vars/traefik_servers/traefik.yml create mode 100644 inventories/demo-mbazürich/host_vars/application/authentik.yml create mode 100644 inventories/demo-mbazürich/host_vars/application/authentik_outpost_ldap.yml create mode 100644 inventories/demo-mbazürich/host_vars/application/bookstack.yml create mode 100644 inventories/demo-mbazürich/host_vars/application/collabora.yml create mode 100644 inventories/demo-mbazürich/host_vars/application/drawio.yml create mode 100644 inventories/demo-mbazürich/host_vars/application/homarr.yml create mode 100644 inventories/demo-mbazürich/host_vars/application/main.yml create mode 100644 inventories/demo-mbazürich/host_vars/application/nextcloud.yml create mode 100644 inventories/demo-mbazürich/host_vars/application/opnform.yml create mode 100644 inventories/demo-mbazürich/host_vars/application/send.yml create mode 100644 inventories/demo-mbazürich/host_vars/application/traefik.yml create mode 100644 inventories/demo-mbazürich/host_vars/reverseproxy/traefik.yml create mode 100644 inventories/demo-mbazürich/host_vars/storage/authentik_outpost_proxy.yml create mode 100644 inventories/demo-mbazürich/host_vars/storage/garage.yml create mode 100644 inventories/demo-mbazürich/host_vars/storage/main.yml create mode 100644 inventories/demo-mbazürich/host_vars/storage/traefik.yml create mode 100644 inventories/demo-phbern/group_vars/all/ansible.yml create mode 100644 inventories/demo-phbern/group_vars/all/docker.yml create mode 100644 inventories/demo-phbern/group_vars/all/vault.yml create mode 100644 inventories/demo-phbern/group_vars/backend_servers/traefik.yml create mode 100644 inventories/demo-phbern/group_vars/traefik_servers/traefik.yml create mode 100644 inventories/demo-phbern/host_vars/application/authentik.yml create mode 100644 inventories/demo-phbern/host_vars/application/authentik_outpost_ldap.yml create mode 100644 inventories/demo-phbern/host_vars/application/bookstack.yml create mode 100644 inventories/demo-phbern/host_vars/application/collabora.yml create mode 100644 inventories/demo-phbern/host_vars/application/drawio.yml create mode 100644 inventories/demo-phbern/host_vars/application/homarr.yml create mode 100644 inventories/demo-phbern/host_vars/application/main.yml create mode 100644 inventories/demo-phbern/host_vars/application/nextcloud.yml create mode 100644 inventories/demo-phbern/host_vars/application/opnform.yml create mode 100644 inventories/demo-phbern/host_vars/application/send.yml create mode 100644 inventories/demo-phbern/host_vars/application/traefik.yml create mode 100644 inventories/demo-phbern/host_vars/reverseproxy/traefik.yml create mode 100644 inventories/demo-phbern/host_vars/storage/authentik_outpost_proxy.yml create mode 100644 inventories/demo-phbern/host_vars/storage/garage.yml create mode 100644 inventories/demo-phbern/host_vars/storage/main.yml create mode 100644 inventories/demo-phbern/host_vars/storage/traefik.yml diff --git a/inventories/demo-mbazürich/group_vars/all/ansible.yml b/inventories/demo-mbazürich/group_vars/all/ansible.yml new file mode 100644 index 0000000..e425179 --- /dev/null +++ b/inventories/demo-mbazürich/group_vars/all/ansible.yml @@ -0,0 +1,2 @@ +--- +ansible_python_interpreter: /usr/bin/python3 diff --git a/inventories/demo-mbazürich/group_vars/all/docker.yml b/inventories/demo-mbazürich/group_vars/all/docker.yml new file mode 100644 index 0000000..73bc820 --- /dev/null +++ b/inventories/demo-mbazürich/group_vars/all/docker.yml @@ -0,0 +1 @@ +docker_registry_mirrors: ["https://registry-mirror.wksbern.ch"] diff --git a/inventories/demo-mbazürich/group_vars/all/vault.yml b/inventories/demo-mbazürich/group_vars/all/vault.yml new file mode 100644 index 0000000..4f59021 --- /dev/null +++ b/inventories/demo-mbazürich/group_vars/all/vault.yml @@ -0,0 +1,2 @@ +vault_addr: "https://bao.digitalboard.ch" +vault_mount: "demo-mbazürich" diff --git a/inventories/demo-mbazürich/group_vars/backend_servers/traefik.yml b/inventories/demo-mbazürich/group_vars/backend_servers/traefik.yml new file mode 100644 index 0000000..9ae17fa --- /dev/null +++ b/inventories/demo-mbazürich/group_vars/backend_servers/traefik.yml @@ -0,0 +1 @@ +traefik_mode: backend diff --git a/inventories/demo-mbazürich/group_vars/traefik_servers/traefik.yml b/inventories/demo-mbazürich/group_vars/traefik_servers/traefik.yml new file mode 100644 index 0000000..e22d467 --- /dev/null +++ b/inventories/demo-mbazürich/group_vars/traefik_servers/traefik.yml @@ -0,0 +1,19 @@ +_acme_tsig: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/acme-tsig', url=vault_addr ) }}" + +traefik_use_ssl: true +traefik_cert_mode: "acme" +traefik_ssl_email: "hostmaster@digitalboard.ch" +traefik_log_level: DEBUG +traefik_network: proxy + +traefik_acme_dns_zone: "demo-mbaz._acme.digitalboard.ch" +traefik_acme_dns_nameserver: "{{ _acme_tsig.server }}" +traefik_acme_tsig_algorithm: "hmac-sha256" +traefik_acme_tsig_key: "{{ _acme_tsig.tsig_key }}" +traefik_acme_tsig_secret: "{{ _acme_tsig.tsig_secret }}" + +# UDP/53 egress from the traefik container reaches ns1.digitalboard.ch +# unreliably (i/o timeouts on lego's recursive SOA pre-check), while +# TCP/53 to the same nameserver is open. Force lego to do its DNS +# lookups over TCP so the DNS-01 challenge can proceed. +traefik_acme_tcp_only: true diff --git a/inventories/demo-mbazürich/host_vars/application/authentik.yml b/inventories/demo-mbazürich/host_vars/application/authentik.yml new file mode 100644 index 0000000..633343b --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/application/authentik.yml @@ -0,0 +1,167 @@ +--- +# Bao secret expected at /data/authentik with keys: +# secret_key, postgres_password, admin_password, +# ldap_outpost_token, proxy_outpost_token, +# nextcloud_oidc_secret, opnform_oidc_secret, +# homarr_oidc_secret, bookstack_oidc_secret +_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" + +# First entry is the canonical public FQDN (browsers + OIDC iss-claim). +# The *.int.* entry makes the router match the internal name too, so +# Traefik requests a cert covering it — the DMZ reverseproxy uses +# auth.int.mbaz as backend_host and verifies the chain (acme mode, +# no insecureSkipVerify), which fails against the default cert otherwise. +authentik_domains: + - "auth.mbaz.souveredu.ch" + - "auth.int.mbaz.souveredu.ch" + +authentik_secret_key: "{{ _authentik.secret_key }}" +authentik_postgres_password: "{{ _authentik.postgres_password }}" + +# LDAP outpost (provider for nextcloud) +authentik_ldap_apps: + - slug: ldap + name: LDAP + base_dn: "dc=mbaz,dc=souveredu,dc=ch" + search_group: admins + +authentik_ldap_outpost: + name: "ldap-outpost" + token: "{{ _authentik.ldap_outpost_token }}" + config: + # 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.mbaz.souveredu.ch/" + log_level: "info" + +# Proxy providers (ForwardAuth) — gate downstream services behind +# authentik. The embedded outpost (which authentik ships out of the box) +# hosts these providers under /outpost.goauthentik.io/auth/traefik on the +# canonical FQDN; the service-side traefik attaches a ForwardAuth +# middleware that talks to that endpoint. +authentik_proxy_apps: + - slug: drawio + name: Drawio + external_host: "https://draw.mbaz.souveredu.ch" + internal_host: "http://drawio:8080" + # drawio is embedded in Nextcloud as an iframe (nextcloud_enable_drawio). + # Every authenticated Nextcloud user must therefore pass the ForwardAuth + # gate, otherwise the editor loads a 403 inside the iframe. Allow both + # standard groups; tightening this back to admins-only would break the + # Nextcloud integration for regular users. + allowed_groups: + - admins + - users + flows: + authentication_slug: default-authentication-flow + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + - slug: garage-webui + name: "Garage S3 Console" + external_host: "https://console.s3.mbaz.souveredu.ch" + internal_host: "http://garage-webui:3909" + allowed_groups: + - admins + flows: + authentication_slug: default-authentication-flow + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + +# Outpost bindings. drawio runs on this (application) host, so its +# ForwardAuth can use the embedded outpost (same host:9000 as the +# authentik server). garage-webui runs on the storage host; a cross-host +# ForwardAuth to the embedded outpost arrives with a mangled +# X-Forwarded-Host (an extra reverseproxy hop appends :443 / a comma list) +# and the outpost then fails to match the provider's external_host (404). +# So garage-webui is bound to a dedicated proxy outpost deployed on +# storage (role digitalboard.core.authentik_outpost_proxy), whose +# ForwardAuth endpoint the local storage Traefik reaches over the docker +# network — no extra hop, clean X-Forwarded-Host. +authentik_proxy_outposts: + - name: "authentik Embedded Outpost" + type: proxy + providers: + - drawio + - name: "storage-proxy-outpost" + type: proxy + token: "{{ _authentik.proxy_outpost_token }}" + providers: + - garage-webui + # config is required by the outpost blueprint serializer. The outpost + # runs on the storage host and reaches authentik over the public FQDN + # (resolves to the DMZ reverseproxy the storage subnet can reach). + config: + authentik_host: "https://auth.mbaz.souveredu.ch/" + log_level: "info" + +# OIDC clients +authentik_oidc_apps: + - slug: nextcloud + name: Nextcloud + client_id: nextcloud + client_secret: "{{ _authentik.nextcloud_oidc_secret }}" + redirect_uris: + - url: "https://cloud.mbaz.souveredu.ch/apps/user_oidc/code" + matching_mode: strict + signing_key_name: "authentik Self-signed Certificate" + flows: + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + scopes: [openid, email, profile, offline_access] + - slug: opnform + name: OpnForm + client_id: opnform + client_secret: "{{ _authentik.opnform_oidc_secret }}" + redirect_uris: + - url: "https://forms.mbaz.souveredu.ch/auth/authentik/callback" + matching_mode: strict + signing_key_name: "authentik Self-signed Certificate" + flows: + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + # No separate `groups` scope — authentik's default `profile` mapping + # already emits a `groups` claim built from request.user.groups, so + # OpnForm's admin-group mapping works without an extra scope. + scopes: [openid, email, profile] + - slug: homarr + name: Homarr + client_id: homarr + client_secret: "{{ _authentik.homarr_oidc_secret }}" + redirect_uris: + - url: "https://home.mbaz.souveredu.ch/api/auth/callback/oidc" + matching_mode: strict + signing_key_name: "authentik Self-signed Certificate" + flows: + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + scopes: [openid, email, profile] + - slug: bookstack + name: BookStack + client_id: bookstack + client_secret: "{{ _authentik.bookstack_oidc_secret }}" + redirect_uris: + - url: "https://wiki.mbaz.souveredu.ch/oidc/callback" + matching_mode: strict + signing_key_name: "authentik Self-signed Certificate" + flows: + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + scopes: [openid, email, profile] + +authentik_groups: + - name: admins + - name: users + - name: opnform-admins + - name: homarr-admins + - name: bookstack-admins + +authentik_local_users: + - username: akadmin + name: "Authentik Admin" + email: "admin@mbaz.souveredu.ch" + password: "{{ _authentik.admin_password }}" + is_active: true + groups: + - authentik Admins + - admins diff --git a/inventories/demo-mbazürich/host_vars/application/authentik_outpost_ldap.yml b/inventories/demo-mbazürich/host_vars/application/authentik_outpost_ldap.yml new file mode 100644 index 0000000..89aa0f6 --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/application/authentik_outpost_ldap.yml @@ -0,0 +1,13 @@ +--- +# Same token as authentik_ldap_outpost.token above — outpost uses it to +# authenticate against the authentik server it talks to. +_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" + +# 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.mbaz.souveredu.ch" +authentik_outpost_ldap_token: "{{ _authentik.ldap_outpost_token }}" +# auth.mba.* presents a valid Let's Encrypt cert via the reverseproxy, so +# verify the chain instead of relying on the role's insecure default. +authentik_outpost_ldap_insecure: "false" diff --git a/inventories/demo-mbazürich/host_vars/application/bookstack.yml b/inventories/demo-mbazürich/host_vars/application/bookstack.yml new file mode 100644 index 0000000..8f02b50 --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/application/bookstack.yml @@ -0,0 +1,36 @@ +--- +# Bao secret /data/bookstack expected to contain: +# db_root_password, db_password, admin_password, oidc_client_secret, +# app_key (optional — only set when restoring) +_bookstack: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/bookstack', url=vault_addr) }}" + +bookstack_domain: "wiki.mbaz.souveredu.ch" +bookstack_extra_domains: + - "wiki.int.mbaz.souveredu.ch" +bookstack_base_url: "https://wiki.mbaz.souveredu.ch" + +# Override the role-default certresolver ("le") with the value used +# across this demo (matches traefik_ssl_cert_resolver in group_vars). +bookstack_traefik_certresolver: "dns" + +bookstack_db_root_password: "{{ _bookstack.db_root_password }}" +bookstack_db_password: "{{ _bookstack.db_password }}" +bookstack_admin_password: "{{ _bookstack.admin_password }}" +bookstack_admin_email: "admin@mbaz.souveredu.ch" +bookstack_admin_name: "BookStack Admin" + +# OIDC against Authentik. BookStack compares OIDC_ISSUER strictly against +# the `iss` claim in the discovery response, and Authentik emits the +# public auth.mbaz.* 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.mbaz.souveredu.ch/application/o/bookstack/" +bookstack_oidc_client_id: "bookstack" +bookstack_oidc_client_secret: "{{ _bookstack.oidc_client_secret }}" +bookstack_oidc_additional_scopes: "openid profile email" +bookstack_oidc_user_to_groups: true +bookstack_oidc_groups_claim: "groups" +bookstack_oidc_auto_initiate: false diff --git a/inventories/demo-mbazürich/host_vars/application/collabora.yml b/inventories/demo-mbazürich/host_vars/application/collabora.yml new file mode 100644 index 0000000..c7b202a --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/application/collabora.yml @@ -0,0 +1,16 @@ +--- +# First entry is the canonical public FQDN. Additional entries cover +# internal *.int.* names so nextcloud's WOPI discovery hits collabora +# in the LAN with a valid internal cert. +collabora_domains: + - "office.mbaz.souveredu.ch" + - "office.int.mbaz.souveredu.ch" + +# Hosts allowed to issue WOPI calls. Both names are listed so collabora +# accepts the callback from nextcloud regardless of which FQDN it uses. +collabora_allowed_domains: + - "cloud.mbaz.souveredu.ch" + - "cloud.int.mbaz.souveredu.ch" + +collabora_frame_ancestors: + - "cloud.mbaz.souveredu.ch" diff --git a/inventories/demo-mbazürich/host_vars/application/drawio.yml b/inventories/demo-mbazürich/host_vars/application/drawio.yml new file mode 100644 index 0000000..5782b7e --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/application/drawio.yml @@ -0,0 +1,20 @@ +--- +drawio_domain: "draw.mbaz.souveredu.ch" + +# Internal FQDN the DMZ reverseproxy uses as backend host so its TLS +# verify matches a cert SAN (the canonical IP-only route has no SAN +# and breaks with "cannot validate certificate ... no IP SANs"). Same +# split-horizon pattern as cloud.int.* / auth.int.* / office.int.*. +drawio_extra_domains: + - "draw.int.mbaz.souveredu.ch" + +# Gate drawio behind the authentik embedded outpost. The allow-list is +# managed on the authentik proxy application (admins + users) so the +# Nextcloud drawio iframe works for every authenticated user. +# ForwardAuth talks to the embedded outpost on the authentik server's +# in-network address. Going via the public FQDN routes through a second +# traefik hop that strips/rewrites X-Forwarded-Host, which breaks +# authentik's provider matching (it returns 404). Plain HTTP to the +# container is the path docs recommend for the embedded outpost. +drawio_authentik_forward_auth: true +drawio_authentik_forward_auth_url: "http://authentik-server-1:9000/outpost.goauthentik.io/auth/traefik" diff --git a/inventories/demo-mbazürich/host_vars/application/homarr.yml b/inventories/demo-mbazürich/host_vars/application/homarr.yml new file mode 100644 index 0000000..9bee263 --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/application/homarr.yml @@ -0,0 +1,78 @@ +--- +# Bao secret /data/homarr expected to contain: +# secret_encryption_key (64 hex chars), admin_password, oidc_client_secret +_homarr: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/homarr', url=vault_addr) }}" + +homarr_domain: "home.mbaz.souveredu.ch" +homarr_extra_domains: + - "home.int.mbaz.souveredu.ch" +homarr_base_url: "https://home.mbaz.souveredu.ch" + +homarr_secret_encryption_key: "{{ _homarr.secret_encryption_key }}" +homarr_admin_username: "admin" +homarr_admin_email: "admin@mbaz.souveredu.ch" +homarr_admin_password: "{{ _homarr.admin_password }}" + +# OIDC against Authentik. credentials provider stays enabled as a +# break-glass account — reach it via /auth/login/credentials when +# AUTH_OIDC_AUTO_LOGIN bypasses the normal /login page. +# +# Issuer must match the `iss` claim authentik emits, which is always the +# 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.mbaz.souveredu.ch/application/o/homarr/" +homarr_oidc_client_id: "homarr" +homarr_oidc_client_secret: "{{ _homarr.oidc_client_secret }}" +homarr_oidc_client_name: "Authentik" +homarr_oidc_scopes: "openid profile email groups" +homarr_oidc_groups_attribute: "groups" +homarr_oidc_auto_login: "true" + +# Default board with shortcuts to the other mbazürich services. Width +# values describe horizontal grid cells (1-10 desktop / 6 tablet / 2 +# mobile, packed left-to-right). +homarr_apps: + - id: nextcloud + name: Nextcloud + description: "Cloud Storage & Collaboration" + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nextcloud.png + href: https://cloud.mbaz.souveredu.ch + width: 2 + - id: collabora + name: Collabora Office + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/collaboraonline.png + href: https://office.mbaz.souveredu.ch + width: 2 + - id: drawio + name: Draw.io + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/drawio.png + href: https://draw.mbaz.souveredu.ch + width: 2 + - id: send + name: Send + description: "Encrypted file-share" + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/firefox-send.png + href: https://send.mbaz.souveredu.ch + width: 2 + - id: opnform + name: OpnForm + description: "Self-hosted forms" + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/opnform.png + href: https://forms.mbaz.souveredu.ch + width: 2 + - id: bookstack + name: BookStack + description: "Wiki & documentation" + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/bookstack.png + href: https://wiki.mbaz.souveredu.ch + width: 2 + - id: authentik + name: Authentik + description: "Identity provider" + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/authentik.png + href: https://auth.mbaz.souveredu.ch + width: 2 diff --git a/inventories/demo-mbazürich/host_vars/application/main.yml b/inventories/demo-mbazürich/host_vars/application/main.yml new file mode 100644 index 0000000..49248a6 --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/application/main.yml @@ -0,0 +1,3 @@ +--- +# application host runs: authentik, authentik-ldap-outpost, +# nextcloud, collabora, drawio diff --git a/inventories/demo-mbazürich/host_vars/application/nextcloud.yml b/inventories/demo-mbazürich/host_vars/application/nextcloud.yml new file mode 100644 index 0000000..5ae6e7e --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/application/nextcloud.yml @@ -0,0 +1,122 @@ +--- +# Bao secret /data/nextcloud expected to contain: +# postgres_password, admin_password +_nextcloud: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/nextcloud', url=vault_addr) }}" +_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" + +# No nextcloud_image pin: the role default (nextcloud:fpm) already ships +# the fix for the PHP UserConfig::getValueBool TypeError (#59629), so the +# 33.0.3 pin gymb still carries is unnecessary here. + +# First entry is the canonical public FQDN (used for OVERWRITEHOST and +# OIDC redirects). Additional entries cover internal *.int.* names so +# collabora's WOPI callbacks hit nextcloud on a name with a valid +# internal cert instead of routing through the DMZ. +nextcloud_domains: + - "cloud.mbaz.souveredu.ch" + - "cloud.int.mbaz.souveredu.ch" +nextcloud_postgres_password: "{{ _nextcloud.postgres_password }}" +nextcloud_admin_user: admin +nextcloud_admin_password: "{{ _nextcloud.admin_password }}" + +nextcloud_enable_notify_push: true +# Use the internal FQDN for the notify_push setup check so curl from the +# nextcloud container hits the local traefik directly instead of +# hairpinning through the DMZ reverseproxy. +nextcloud_notify_push_domain: "cloud.int.mbaz.souveredu.ch" + +# Collabora integration +# wopi_url (server-to-server: nextcloud calls collabora for discovery / +# capabilities) goes to the internal FQDN so the call stays in the LAN. +# public_wopi_url is what the browser loads the office iframe from — that +# stays on the public name reachable through the DMZ. +nextcloud_enable_collabora: true +nextcloud_collabora_domain: "office.int.mbaz.souveredu.ch" +nextcloud_collabora_public_domain: "office.mbaz.souveredu.ch" + +# Draw.io integration +nextcloud_enable_drawio: true +nextcloud_drawio_url: "https://draw.mbaz.souveredu.ch" + +nextcloud_apps_to_install: + - groupfolders + - richdocuments + - spreed + - user_ldap + - user_oidc + - whiteboard + - drawio + - files_lock + - notify_push + +# S3 primary storage via Garage — server-to-server, so use the internal FQDN. +# Resolves through the internal DNS to the storage host and presents a valid +# cert from the local traefik on storage. +nextcloud_use_s3_storage: true +nextcloud_s3_key: "{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='storage')['key_id'] }}" +nextcloud_s3_secret: "{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='storage')['secret_key'] }}" +nextcloud_s3_bucket: "nextcloud" +nextcloud_s3_host: "s3.int.mbaz.souveredu.ch" +nextcloud_s3_port: 443 +nextcloud_s3_ssl: true +nextcloud_s3_usepath_style: true + +# OIDC discovery/token/userinfo and the S3 backend both target FQDNs +# that resolve to RFC1918 addresses (auth.mba.* 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 + +# LDAP backend (Authentik LDAP outpost) +nextcloud_ldap_enabled: true +nextcloud_ldap_config: + ldapHost: "ldap://authentik-outpost-ldap-ldap-1" + ldapPort: "3389" + ldapAgentName: "cn=akadmin,ou=users,dc=mbaz,dc=souveredu,dc=ch" + ldapAgentPassword: "{{ _authentik.admin_password }}" + ldapBase: "dc=mbaz,dc=souveredu,dc=ch" + ldapBaseUsers: "ou=users,dc=mbaz,dc=souveredu,dc=ch" + ldapTLS: "0" + turnOffCertCheck: "1" + ldapUserFilter: "(&(objectClass=user)(cn=*))" + ldapUserFilterObjectclass: "user" + ldapLoginFilter: "(&(objectClass=user)(cn=%uid))" + ldapLoginFilterUsername: "1" + ldapUserDisplayName: "cn" + ldapEmailAttribute: "mail" + ldapExpertUsernameAttr: "cn" + ldapExpertUUIDUserAttr: "uid" + ldapExpertUUIDGroupAttr: "uid" + ldapBaseGroups: "ou=groups,dc=mbaz,dc=souveredu,dc=ch" + ldapGroupFilter: "(&(objectClass=group))" + ldapGroupFilterObjectclass: "group" + ldapGroupDisplayName: "cn" + ldapGroupMemberAssocAttr: "member" + ldapAdminGroup: "admins" + ldapCacheTTL: "600" + ldapPagingSize: "500" + ldapExperiencedAdmin: "1" + ldapConfigurationActive: "1" + +# OIDC providers for login (Authentik) +nextcloud_oidc_providers: + - identifier: authentik + display_name: "Login with Authentik" + client_id: nextcloud + client_secret: "{{ _authentik.nextcloud_oidc_secret }}" + # 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.mbaz.souveredu.ch/application/o/nextcloud/.well-known/openid-configuration" + scope: "openid email profile" + unique_uid: true + mapping: + uid: preferred_username + display_name: name + email: email diff --git a/inventories/demo-mbazürich/host_vars/application/opnform.yml b/inventories/demo-mbazürich/host_vars/application/opnform.yml new file mode 100644 index 0000000..fa3edd7 --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/application/opnform.yml @@ -0,0 +1,50 @@ +--- +# Bao secret /data/opnform expected to contain: +# app_key (must start with "base64:"), jwt_secret, front_api_secret, +# db_password, admin_password, oidc_client_secret +_opnform: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/opnform', url=vault_addr) }}" +_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" + +opnform_domain: "forms.mbaz.souveredu.ch" +opnform_extra_domains: + - "forms.int.mbaz.souveredu.ch" +opnform_base_url: "https://forms.mbaz.souveredu.ch" + +opnform_app_key: "{{ _opnform.app_key }}" +opnform_jwt_secret: "{{ _opnform.jwt_secret }}" +opnform_front_api_secret: "{{ _opnform.front_api_secret }}" +opnform_db_password: "{{ _opnform.db_password }}" + +# Bootstrap admin via API on first run so the manual setup page is +# skipped. The admin credentials are also required to seed the OIDC +# IdentityConnection through OpnForm's API (only an authenticated admin +# can create connections). +opnform_admin_name: "OpnForm Admin" +opnform_admin_email: "admin@mbaz.souveredu.ch" +opnform_admin_password: "{{ _opnform.admin_password }}" + +# OIDC against Authentik. The public auth.mbaz.* 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 FQDN: OpnForm validates the token's `iss` +# claim against this value, and Authentik emits the public hostname there. +opnform_oidc_issuer: "https://auth.mbaz.souveredu.ch/application/o/opnform/" +opnform_oidc_client_id: "opnform" +opnform_oidc_client_secret: "{{ _opnform.oidc_client_secret }}" +opnform_oidc_client_name: "Authentik" +opnform_oidc_slug: "authentik" +opnform_oidc_domain: "mbaz.souveredu.ch" +opnform_oidc_admin_group: "opnform-admins" + +# Disable password login entirely — every user goes through Authentik. +# All real users have @mbaz.souveredu.ch addresses (matching +# opnform_oidc_domain above), so no password fallback is needed. +opnform_oidc_force_login: true + +# `/` and `/login` are intercepted and jump straight to Authentik. +# Public form deep-links (`/forms/`, `/admin/...`) keep working. +# Break-glass: /login?bypass=1 reaches the email form when the IdP is +# down. +opnform_oidc_sso_entrypoint: true diff --git a/inventories/demo-mbazürich/host_vars/application/send.yml b/inventories/demo-mbazürich/host_vars/application/send.yml new file mode 100644 index 0000000..58cee46 --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/application/send.yml @@ -0,0 +1,8 @@ +--- +# Send: anonymized self-hosted file-share (no login). First entry is the +# canonical public FQDN (used as BASE_URL); the *.int.* entry covers the +# server-to-server hop from the DMZ reverseproxy with a cert SAN that +# matches the backend hostname (same split-horizon pattern as cloud/draw). +send_domains: + - "send.mbaz.souveredu.ch" + - "send.int.mbaz.souveredu.ch" diff --git a/inventories/demo-mbazürich/host_vars/application/traefik.yml b/inventories/demo-mbazürich/host_vars/application/traefik.yml new file mode 100644 index 0000000..395443a --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/application/traefik.yml @@ -0,0 +1,46 @@ +--- +# Services hosted on `application` that the DMZ reverseproxy should +# forward public traffic to. The DMZ traefik picks this up via +# hostvars[backend].traefik_dmz_exposed_services and renders a router + +# service for each entry into /config/services.yml. +traefik_dmz_exposed_services: + - name: authentik + domain: auth.mbaz.souveredu.ch + backend_host: auth.int.mbaz.souveredu.ch + port: 443 + protocol: https + - name: nextcloud + domain: cloud.mbaz.souveredu.ch + backend_host: cloud.int.mbaz.souveredu.ch + port: 443 + protocol: https + - name: collabora + domain: office.mbaz.souveredu.ch + backend_host: office.int.mbaz.souveredu.ch + port: 443 + protocol: https + - name: drawio + domain: draw.mbaz.souveredu.ch + backend_host: draw.int.mbaz.souveredu.ch + port: 443 + protocol: https + - name: send + domain: send.mbaz.souveredu.ch + backend_host: send.int.mbaz.souveredu.ch + port: 443 + protocol: https + - name: opnform + domain: forms.mbaz.souveredu.ch + backend_host: forms.int.mbaz.souveredu.ch + port: 443 + protocol: https + - name: homarr + domain: home.mbaz.souveredu.ch + backend_host: home.int.mbaz.souveredu.ch + port: 443 + protocol: https + - name: bookstack + domain: wiki.mbaz.souveredu.ch + backend_host: wiki.int.mbaz.souveredu.ch + port: 443 + protocol: https diff --git a/inventories/demo-mbazürich/host_vars/reverseproxy/traefik.yml b/inventories/demo-mbazürich/host_vars/reverseproxy/traefik.yml new file mode 100644 index 0000000..48f6d08 --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/reverseproxy/traefik.yml @@ -0,0 +1,22 @@ +--- +traefik_mode: dmz + +# The DMZ traefik discovers which services to expose by reading +# traefik_dmz_exposed_services from each backend host's host_vars +# (application/traefik.yml, storage/traefik.yml). See the role's +# tasks/main.yml — set_fact "Build service registry from backend +# servers (DMZ mode)". + +# From the DMZ network the public ns1 IP (193.43.183.169) is not +# reachable on port 53, but the internal address (172.16.9.169) is. +# Override the group-level traefik_acme_dns_nameserver from bao so +# lego's RFC2136 updates land at the internal interface. The TSIG +# key/secret are the same; only the transport target changes. +traefik_acme_dns_nameserver: "172.16.9.169" + +# Lego's propagation check normally polls the NS hostnames listed in +# the zone's SOA (ns1.digitalboard.ch.) — which resolves to the +# public IP that's unreachable from this DMZ host. Skip that check; +# lego still polls via the resolver above before asking LE to +# validate. +traefik_acme_disable_ans_checks: true diff --git a/inventories/demo-mbazürich/host_vars/storage/authentik_outpost_proxy.yml b/inventories/demo-mbazürich/host_vars/storage/authentik_outpost_proxy.yml new file mode 100644 index 0000000..d0cb1df --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/storage/authentik_outpost_proxy.yml @@ -0,0 +1,19 @@ +--- +# Proxy (ForwardAuth) outpost co-located with garage on storage. The +# garage-webui Traefik router's ForwardAuth middleware points at this +# container over the local `proxy` docker network, so the auth subrequest +# never leaves storage — the embedded outpost on the application host +# can't be used here because the cross-host hop mangles X-Forwarded-Host. +_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" + +# Outpost pulls its config from authentik over the public FQDN (resolves +# via the internal DNS view to the DMZ reverseproxy the storage subnet can +# reach). Token must match the storage-proxy-outpost registered in +# authentik (see application/authentik.yml authentik_proxy_outposts). +# Verify the authentik TLS chain: auth.mba.* presents a valid Let's +# Encrypt cert via the reverseproxy and the storage subnet reaches it, so +# there's no reason to disable verification (verified: ssl_verify_result=0). +authentik_outpost_proxy_host: "https://auth.mbaz.souveredu.ch" +authentik_outpost_proxy_token: "{{ _authentik.proxy_outpost_token }}" +authentik_outpost_proxy_insecure: "false" +authentik_outpost_proxy_network: "proxy" diff --git a/inventories/demo-mbazürich/host_vars/storage/garage.yml b/inventories/demo-mbazürich/host_vars/storage/garage.yml new file mode 100644 index 0000000..cd8949e --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/storage/garage.yml @@ -0,0 +1,51 @@ +--- +# Bao secret /data/garage expected to contain: +# rpc_secret, admin_token, metrics_token, webui_password +_garage: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/garage', url=vault_addr) }}" + +# First entry is the canonical public S3 FQDN. Additional entries +# cover internal *.int.* names so server-to-server S3 traffic (e.g. +# nextcloud → garage) stays in the LAN. +garage_s3_domains: + - "s3.mbaz.souveredu.ch" + - "s3.int.mbaz.souveredu.ch" +# First entry is the canonical public console FQDN. The *.int.* entry +# gives the DMZ a backend_host whose cert SAN matches (storage/traefik.yml), +# so the DMZ->storage hop verifies TLS instead of failing on a raw IP. +garage_webui_domains: + - "console.s3.mbaz.souveredu.ch" + - "console.s3.int.mbaz.souveredu.ch" +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. +# +# ForwardAuth talks to the storage-local proxy outpost over the docker +# network (role digitalboard.core.authentik_outpost_proxy). The embedded +# outpost on the application host can't be used cross-host: the extra +# reverseproxy hop mangles X-Forwarded-Host (appends :443 / a comma list) +# and the outpost then 404s instead of matching console.s3.* against the +# provider's external_host. A co-located outpost keeps the subrequest on +# the local network with a clean X-Forwarded-Host. +garage_webui_authentik_forward_auth: true +garage_webui_authentik_forward_auth_url: "http://authentik-outpost-proxy-proxy-1:9000/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') }}" + +garage_rpc_secret: "{{ _garage.rpc_secret }}" +garage_admin_token: "{{ _garage.admin_token }}" +garage_metrics_token: "{{ _garage.metrics_token }}" + +# Initial cluster bootstrap (single-node) +garage_bootstrap_enabled: true +garage_bootstrap_zone: "zurich1" +garage_bootstrap_capacity: "100G" + +# Buckets and keys consumed by nextcloud +garage_s3_keys: + - name: nextcloud + buckets: + - name: nextcloud + permissions: ["read", "write"] diff --git a/inventories/demo-mbazürich/host_vars/storage/main.yml b/inventories/demo-mbazürich/host_vars/storage/main.yml new file mode 100644 index 0000000..75e487d --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/storage/main.yml @@ -0,0 +1,2 @@ +--- +# storage host runs: garage (S3 + WebUI) diff --git a/inventories/demo-mbazürich/host_vars/storage/traefik.yml b/inventories/demo-mbazürich/host_vars/storage/traefik.yml new file mode 100644 index 0000000..4d00cef --- /dev/null +++ b/inventories/demo-mbazürich/host_vars/storage/traefik.yml @@ -0,0 +1,21 @@ +--- +# Services hosted on `storage` that the DMZ reverseproxy should forward +# public traffic to. See application/traefik.yml for the mechanism. +# The garage-webui ForwardAuth no longer needs an auth FQDN pinned here — +# it talks to the storage-local proxy outpost over the docker network +# (see garage.yml), and the outpost reaches authentik via the public FQDN +# over the reverseproxy (firewall now permits backend -> DMZ). +traefik_dmz_exposed_services: + - name: garage-s3 + domain: s3.mbaz.souveredu.ch + backend_host: s3.int.mbaz.souveredu.ch + port: 443 + protocol: https + - name: garage-webui + domain: console.s3.mbaz.souveredu.ch + # Internal name so the DMZ verifies the storage backend cert against a + # matching SAN (acme cert_mode, no insecureSkipVerify). garage now + # serves this SAN via garage_webui_domains (see garage.yml). + backend_host: console.s3.int.mbaz.souveredu.ch + port: 443 + protocol: https diff --git a/inventories/demo-mbazürich/hosts.yml b/inventories/demo-mbazürich/hosts.yml index 6185519..4e7f7d4 100644 --- a/inventories/demo-mbazürich/hosts.yml +++ b/inventories/demo-mbazürich/hosts.yml @@ -22,6 +22,7 @@ all: backend_servers: hosts: application: + storage: garage_servers: hosts: @@ -31,10 +32,6 @@ all: hosts: application: - opencloud_servers: - hosts: - application: - collabora_servers: hosts: application: @@ -45,4 +42,33 @@ all: authentik_servers: hosts: - application: \ No newline at end of file + application: + + authentik_outpost_ldap_servers: + hosts: + application: + + # Proxy (ForwardAuth) outpost co-located with garage on storage, so + # the garage-webui ForwardAuth subrequest stays on the local docker + # network instead of crossing an extra reverseproxy hop (which mangles + # X-Forwarded-Host and breaks the embedded outpost's app matching). + authentik_outpost_proxy_servers: + hosts: + storage: + + + send_servers: + hosts: + application: + + opnform_servers: + hosts: + application: + + homarr_servers: + hosts: + application: + + bookstack_servers: + hosts: + application: diff --git a/inventories/demo-phbern/group_vars/all/ansible.yml b/inventories/demo-phbern/group_vars/all/ansible.yml new file mode 100644 index 0000000..e425179 --- /dev/null +++ b/inventories/demo-phbern/group_vars/all/ansible.yml @@ -0,0 +1,2 @@ +--- +ansible_python_interpreter: /usr/bin/python3 diff --git a/inventories/demo-phbern/group_vars/all/docker.yml b/inventories/demo-phbern/group_vars/all/docker.yml new file mode 100644 index 0000000..73bc820 --- /dev/null +++ b/inventories/demo-phbern/group_vars/all/docker.yml @@ -0,0 +1 @@ +docker_registry_mirrors: ["https://registry-mirror.wksbern.ch"] diff --git a/inventories/demo-phbern/group_vars/all/vault.yml b/inventories/demo-phbern/group_vars/all/vault.yml new file mode 100644 index 0000000..bd56791 --- /dev/null +++ b/inventories/demo-phbern/group_vars/all/vault.yml @@ -0,0 +1,2 @@ +vault_addr: "https://bao.digitalboard.ch" +vault_mount: "demo-phbern" diff --git a/inventories/demo-phbern/group_vars/backend_servers/traefik.yml b/inventories/demo-phbern/group_vars/backend_servers/traefik.yml new file mode 100644 index 0000000..9ae17fa --- /dev/null +++ b/inventories/demo-phbern/group_vars/backend_servers/traefik.yml @@ -0,0 +1 @@ +traefik_mode: backend diff --git a/inventories/demo-phbern/group_vars/traefik_servers/traefik.yml b/inventories/demo-phbern/group_vars/traefik_servers/traefik.yml new file mode 100644 index 0000000..082170b --- /dev/null +++ b/inventories/demo-phbern/group_vars/traefik_servers/traefik.yml @@ -0,0 +1,19 @@ +_acme_tsig: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/acme-tsig', url=vault_addr ) }}" + +traefik_use_ssl: true +traefik_cert_mode: "acme" +traefik_ssl_email: "hostmaster@digitalboard.ch" +traefik_log_level: DEBUG +traefik_network: proxy + +traefik_acme_dns_zone: "demo-phbe._acme.digitalboard.ch" +traefik_acme_dns_nameserver: "{{ _acme_tsig.server }}" +traefik_acme_tsig_algorithm: "hmac-sha256" +traefik_acme_tsig_key: "{{ _acme_tsig.tsig_key }}" +traefik_acme_tsig_secret: "{{ _acme_tsig.tsig_secret }}" + +# UDP/53 egress from the traefik container reaches ns1.digitalboard.ch +# unreliably (i/o timeouts on lego's recursive SOA pre-check), while +# TCP/53 to the same nameserver is open. Force lego to do its DNS +# lookups over TCP so the DNS-01 challenge can proceed. +traefik_acme_tcp_only: true diff --git a/inventories/demo-phbern/host_vars/application/authentik.yml b/inventories/demo-phbern/host_vars/application/authentik.yml new file mode 100644 index 0000000..1c4759d --- /dev/null +++ b/inventories/demo-phbern/host_vars/application/authentik.yml @@ -0,0 +1,167 @@ +--- +# Bao secret expected at /data/authentik with keys: +# secret_key, postgres_password, admin_password, +# ldap_outpost_token, proxy_outpost_token, +# nextcloud_oidc_secret, opnform_oidc_secret, +# homarr_oidc_secret, bookstack_oidc_secret +_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" + +# First entry is the canonical public FQDN (browsers + OIDC iss-claim). +# The *.int.* entry makes the router match the internal name too, so +# Traefik requests a cert covering it — the DMZ reverseproxy uses +# auth.int.phbe as backend_host and verifies the chain (acme mode, +# no insecureSkipVerify), which fails against the default cert otherwise. +authentik_domains: + - "auth.phbe.souveredu.ch" + - "auth.int.phbe.souveredu.ch" + +authentik_secret_key: "{{ _authentik.secret_key }}" +authentik_postgres_password: "{{ _authentik.postgres_password }}" + +# LDAP outpost (provider for nextcloud) +authentik_ldap_apps: + - slug: ldap + name: LDAP + base_dn: "dc=phbe,dc=souveredu,dc=ch" + search_group: admins + +authentik_ldap_outpost: + name: "ldap-outpost" + token: "{{ _authentik.ldap_outpost_token }}" + config: + # 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.phbe.souveredu.ch/" + log_level: "info" + +# Proxy providers (ForwardAuth) — gate downstream services behind +# authentik. The embedded outpost (which authentik ships out of the box) +# hosts these providers under /outpost.goauthentik.io/auth/traefik on the +# canonical FQDN; the service-side traefik attaches a ForwardAuth +# middleware that talks to that endpoint. +authentik_proxy_apps: + - slug: drawio + name: Drawio + external_host: "https://draw.phbe.souveredu.ch" + internal_host: "http://drawio:8080" + # drawio is embedded in Nextcloud as an iframe (nextcloud_enable_drawio). + # Every authenticated Nextcloud user must therefore pass the ForwardAuth + # gate, otherwise the editor loads a 403 inside the iframe. Allow both + # standard groups; tightening this back to admins-only would break the + # Nextcloud integration for regular users. + allowed_groups: + - admins + - users + flows: + authentication_slug: default-authentication-flow + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + - slug: garage-webui + name: "Garage S3 Console" + external_host: "https://console.s3.phbe.souveredu.ch" + internal_host: "http://garage-webui:3909" + allowed_groups: + - admins + flows: + authentication_slug: default-authentication-flow + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + +# Outpost bindings. drawio runs on this (application) host, so its +# ForwardAuth can use the embedded outpost (same host:9000 as the +# authentik server). garage-webui runs on the storage host; a cross-host +# ForwardAuth to the embedded outpost arrives with a mangled +# X-Forwarded-Host (an extra reverseproxy hop appends :443 / a comma list) +# and the outpost then fails to match the provider's external_host (404). +# So garage-webui is bound to a dedicated proxy outpost deployed on +# storage (role digitalboard.core.authentik_outpost_proxy), whose +# ForwardAuth endpoint the local storage Traefik reaches over the docker +# network — no extra hop, clean X-Forwarded-Host. +authentik_proxy_outposts: + - name: "authentik Embedded Outpost" + type: proxy + providers: + - drawio + - name: "storage-proxy-outpost" + type: proxy + token: "{{ _authentik.proxy_outpost_token }}" + providers: + - garage-webui + # config is required by the outpost blueprint serializer. The outpost + # runs on the storage host and reaches authentik over the public FQDN + # (resolves to the DMZ reverseproxy the storage subnet can reach). + config: + authentik_host: "https://auth.phbe.souveredu.ch/" + log_level: "info" + +# OIDC clients +authentik_oidc_apps: + - slug: nextcloud + name: Nextcloud + client_id: nextcloud + client_secret: "{{ _authentik.nextcloud_oidc_secret }}" + redirect_uris: + - url: "https://cloud.phbe.souveredu.ch/apps/user_oidc/code" + matching_mode: strict + signing_key_name: "authentik Self-signed Certificate" + flows: + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + scopes: [openid, email, profile, offline_access] + - slug: opnform + name: OpnForm + client_id: opnform + client_secret: "{{ _authentik.opnform_oidc_secret }}" + redirect_uris: + - url: "https://forms.phbe.souveredu.ch/auth/authentik/callback" + matching_mode: strict + signing_key_name: "authentik Self-signed Certificate" + flows: + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + # No separate `groups` scope — authentik's default `profile` mapping + # already emits a `groups` claim built from request.user.groups, so + # OpnForm's admin-group mapping works without an extra scope. + scopes: [openid, email, profile] + - slug: homarr + name: Homarr + client_id: homarr + client_secret: "{{ _authentik.homarr_oidc_secret }}" + redirect_uris: + - url: "https://home.phbe.souveredu.ch/api/auth/callback/oidc" + matching_mode: strict + signing_key_name: "authentik Self-signed Certificate" + flows: + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + scopes: [openid, email, profile] + - slug: bookstack + name: BookStack + client_id: bookstack + client_secret: "{{ _authentik.bookstack_oidc_secret }}" + redirect_uris: + - url: "https://wiki.phbe.souveredu.ch/oidc/callback" + matching_mode: strict + signing_key_name: "authentik Self-signed Certificate" + flows: + authorization_slug: default-provider-authorization-implicit-consent + invalidation_slug: default-provider-invalidation-flow + scopes: [openid, email, profile] + +authentik_groups: + - name: admins + - name: users + - name: opnform-admins + - name: homarr-admins + - name: bookstack-admins + +authentik_local_users: + - username: akadmin + name: "Authentik Admin" + email: "admin@phbe.souveredu.ch" + password: "{{ _authentik.admin_password }}" + is_active: true + groups: + - authentik Admins + - admins diff --git a/inventories/demo-phbern/host_vars/application/authentik_outpost_ldap.yml b/inventories/demo-phbern/host_vars/application/authentik_outpost_ldap.yml new file mode 100644 index 0000000..f3b8931 --- /dev/null +++ b/inventories/demo-phbern/host_vars/application/authentik_outpost_ldap.yml @@ -0,0 +1,13 @@ +--- +# Same token as authentik_ldap_outpost.token above — outpost uses it to +# authenticate against the authentik server it talks to. +_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" + +# 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.phbe.souveredu.ch" +authentik_outpost_ldap_token: "{{ _authentik.ldap_outpost_token }}" +# auth.phbe.* presents a valid Let's Encrypt cert via the reverseproxy, so +# verify the chain instead of relying on the role's insecure default. +authentik_outpost_ldap_insecure: "false" diff --git a/inventories/demo-phbern/host_vars/application/bookstack.yml b/inventories/demo-phbern/host_vars/application/bookstack.yml new file mode 100644 index 0000000..db820c0 --- /dev/null +++ b/inventories/demo-phbern/host_vars/application/bookstack.yml @@ -0,0 +1,36 @@ +--- +# Bao secret /data/bookstack expected to contain: +# db_root_password, db_password, admin_password, oidc_client_secret, +# app_key (optional — only set when restoring) +_bookstack: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/bookstack', url=vault_addr) }}" + +bookstack_domain: "wiki.phbe.souveredu.ch" +bookstack_extra_domains: + - "wiki.int.phbe.souveredu.ch" +bookstack_base_url: "https://wiki.phbe.souveredu.ch" + +# Override the role-default certresolver ("le") with the value used +# across this demo (matches traefik_ssl_cert_resolver in group_vars). +bookstack_traefik_certresolver: "dns" + +bookstack_db_root_password: "{{ _bookstack.db_root_password }}" +bookstack_db_password: "{{ _bookstack.db_password }}" +bookstack_admin_password: "{{ _bookstack.admin_password }}" +bookstack_admin_email: "admin@phbe.souveredu.ch" +bookstack_admin_name: "BookStack Admin" + +# OIDC against Authentik. BookStack compares OIDC_ISSUER strictly against +# the `iss` claim in the discovery response, and Authentik emits the +# public auth.phbe.* 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.phbe.souveredu.ch/application/o/bookstack/" +bookstack_oidc_client_id: "bookstack" +bookstack_oidc_client_secret: "{{ _bookstack.oidc_client_secret }}" +bookstack_oidc_additional_scopes: "openid profile email" +bookstack_oidc_user_to_groups: true +bookstack_oidc_groups_claim: "groups" +bookstack_oidc_auto_initiate: false diff --git a/inventories/demo-phbern/host_vars/application/collabora.yml b/inventories/demo-phbern/host_vars/application/collabora.yml new file mode 100644 index 0000000..c42c49e --- /dev/null +++ b/inventories/demo-phbern/host_vars/application/collabora.yml @@ -0,0 +1,16 @@ +--- +# First entry is the canonical public FQDN. Additional entries cover +# internal *.int.* names so nextcloud's WOPI discovery hits collabora +# in the LAN with a valid internal cert. +collabora_domains: + - "office.phbe.souveredu.ch" + - "office.int.phbe.souveredu.ch" + +# Hosts allowed to issue WOPI calls. Both names are listed so collabora +# accepts the callback from nextcloud regardless of which FQDN it uses. +collabora_allowed_domains: + - "cloud.phbe.souveredu.ch" + - "cloud.int.phbe.souveredu.ch" + +collabora_frame_ancestors: + - "cloud.phbe.souveredu.ch" diff --git a/inventories/demo-phbern/host_vars/application/drawio.yml b/inventories/demo-phbern/host_vars/application/drawio.yml new file mode 100644 index 0000000..fe0f396 --- /dev/null +++ b/inventories/demo-phbern/host_vars/application/drawio.yml @@ -0,0 +1,20 @@ +--- +drawio_domain: "draw.phbe.souveredu.ch" + +# Internal FQDN the DMZ reverseproxy uses as backend host so its TLS +# verify matches a cert SAN (the canonical IP-only route has no SAN +# and breaks with "cannot validate certificate ... no IP SANs"). Same +# split-horizon pattern as cloud.int.* / auth.int.* / office.int.*. +drawio_extra_domains: + - "draw.int.phbe.souveredu.ch" + +# Gate drawio behind the authentik embedded outpost. The allow-list is +# managed on the authentik proxy application (admins + users) so the +# Nextcloud drawio iframe works for every authenticated user. +# ForwardAuth talks to the embedded outpost on the authentik server's +# in-network address. Going via the public FQDN routes through a second +# traefik hop that strips/rewrites X-Forwarded-Host, which breaks +# authentik's provider matching (it returns 404). Plain HTTP to the +# container is the path docs recommend for the embedded outpost. +drawio_authentik_forward_auth: true +drawio_authentik_forward_auth_url: "http://authentik-server-1:9000/outpost.goauthentik.io/auth/traefik" diff --git a/inventories/demo-phbern/host_vars/application/homarr.yml b/inventories/demo-phbern/host_vars/application/homarr.yml new file mode 100644 index 0000000..6357331 --- /dev/null +++ b/inventories/demo-phbern/host_vars/application/homarr.yml @@ -0,0 +1,78 @@ +--- +# Bao secret /data/homarr expected to contain: +# secret_encryption_key (64 hex chars), admin_password, oidc_client_secret +_homarr: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/homarr', url=vault_addr) }}" + +homarr_domain: "home.phbe.souveredu.ch" +homarr_extra_domains: + - "home.int.phbe.souveredu.ch" +homarr_base_url: "https://home.phbe.souveredu.ch" + +homarr_secret_encryption_key: "{{ _homarr.secret_encryption_key }}" +homarr_admin_username: "admin" +homarr_admin_email: "admin@phbe.souveredu.ch" +homarr_admin_password: "{{ _homarr.admin_password }}" + +# OIDC against Authentik. credentials provider stays enabled as a +# break-glass account — reach it via /auth/login/credentials when +# AUTH_OIDC_AUTO_LOGIN bypasses the normal /login page. +# +# Issuer must match the `iss` claim authentik emits, which is always the +# 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.phbe.souveredu.ch/application/o/homarr/" +homarr_oidc_client_id: "homarr" +homarr_oidc_client_secret: "{{ _homarr.oidc_client_secret }}" +homarr_oidc_client_name: "Authentik" +homarr_oidc_scopes: "openid profile email groups" +homarr_oidc_groups_attribute: "groups" +homarr_oidc_auto_login: "true" + +# Default board with shortcuts to the other phbern services. Width +# values describe horizontal grid cells (1-10 desktop / 6 tablet / 2 +# mobile, packed left-to-right). +homarr_apps: + - id: nextcloud + name: Nextcloud + description: "Cloud Storage & Collaboration" + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nextcloud.png + href: https://cloud.phbe.souveredu.ch + width: 2 + - id: collabora + name: Collabora Office + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/collaboraonline.png + href: https://office.phbe.souveredu.ch + width: 2 + - id: drawio + name: Draw.io + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/drawio.png + href: https://draw.phbe.souveredu.ch + width: 2 + - id: send + name: Send + description: "Encrypted file-share" + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/firefox-send.png + href: https://send.phbe.souveredu.ch + width: 2 + - id: opnform + name: OpnForm + description: "Self-hosted forms" + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/opnform.png + href: https://forms.phbe.souveredu.ch + width: 2 + - id: bookstack + name: BookStack + description: "Wiki & documentation" + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/bookstack.png + href: https://wiki.phbe.souveredu.ch + width: 2 + - id: authentik + name: Authentik + description: "Identity provider" + icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/authentik.png + href: https://auth.phbe.souveredu.ch + width: 2 diff --git a/inventories/demo-phbern/host_vars/application/main.yml b/inventories/demo-phbern/host_vars/application/main.yml new file mode 100644 index 0000000..6843222 --- /dev/null +++ b/inventories/demo-phbern/host_vars/application/main.yml @@ -0,0 +1,3 @@ +--- +# application host runs: authentik, authentik-ldap-outpost, +# nextcloud, collabora, drawio, send, opnform, homarr, bookstack diff --git a/inventories/demo-phbern/host_vars/application/nextcloud.yml b/inventories/demo-phbern/host_vars/application/nextcloud.yml new file mode 100644 index 0000000..c3d57a8 --- /dev/null +++ b/inventories/demo-phbern/host_vars/application/nextcloud.yml @@ -0,0 +1,121 @@ +--- +# Bao secret /data/nextcloud expected to contain: +# postgres_password, admin_password +_nextcloud: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/nextcloud', url=vault_addr) }}" +_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" + +# No nextcloud_image pin: the role default (nextcloud:fpm) already ships +# the fix for the PHP UserConfig::getValueBool TypeError (#59629). + +# First entry is the canonical public FQDN (used for OVERWRITEHOST and +# OIDC redirects). Additional entries cover internal *.int.* names so +# collabora's WOPI callbacks hit nextcloud on a name with a valid +# internal cert instead of routing through the DMZ. +nextcloud_domains: + - "cloud.phbe.souveredu.ch" + - "cloud.int.phbe.souveredu.ch" +nextcloud_postgres_password: "{{ _nextcloud.postgres_password }}" +nextcloud_admin_user: admin +nextcloud_admin_password: "{{ _nextcloud.admin_password }}" + +nextcloud_enable_notify_push: true +# Use the internal FQDN for the notify_push setup check so curl from the +# nextcloud container hits the local traefik directly instead of +# hairpinning through the DMZ reverseproxy. +nextcloud_notify_push_domain: "cloud.int.phbe.souveredu.ch" + +# Collabora integration +# wopi_url (server-to-server: nextcloud calls collabora for discovery / +# capabilities) goes to the internal FQDN so the call stays in the LAN. +# public_wopi_url is what the browser loads the office iframe from — that +# stays on the public name reachable through the DMZ. +nextcloud_enable_collabora: true +nextcloud_collabora_domain: "office.int.phbe.souveredu.ch" +nextcloud_collabora_public_domain: "office.phbe.souveredu.ch" + +# Draw.io integration +nextcloud_enable_drawio: true +nextcloud_drawio_url: "https://draw.phbe.souveredu.ch" + +nextcloud_apps_to_install: + - groupfolders + - richdocuments + - spreed + - user_ldap + - user_oidc + - whiteboard + - drawio + - files_lock + - notify_push + +# S3 primary storage via Garage — server-to-server, so use the internal FQDN. +# Resolves through the internal DNS to the storage host and presents a valid +# cert from the local traefik on storage. +nextcloud_use_s3_storage: true +nextcloud_s3_key: "{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='storage')['key_id'] }}" +nextcloud_s3_secret: "{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='storage')['secret_key'] }}" +nextcloud_s3_bucket: "nextcloud" +nextcloud_s3_host: "s3.int.phbe.souveredu.ch" +nextcloud_s3_port: 443 +nextcloud_s3_ssl: true +nextcloud_s3_usepath_style: true + +# OIDC discovery/token/userinfo and the S3 backend both target FQDNs +# that resolve to RFC1918 addresses (auth.phbe.* 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 + +# LDAP backend (Authentik LDAP outpost) +nextcloud_ldap_enabled: true +nextcloud_ldap_config: + ldapHost: "ldap://authentik-outpost-ldap-ldap-1" + ldapPort: "3389" + ldapAgentName: "cn=akadmin,ou=users,dc=phbe,dc=souveredu,dc=ch" + ldapAgentPassword: "{{ _authentik.admin_password }}" + ldapBase: "dc=phbe,dc=souveredu,dc=ch" + ldapBaseUsers: "ou=users,dc=phbe,dc=souveredu,dc=ch" + ldapTLS: "0" + turnOffCertCheck: "1" + ldapUserFilter: "(&(objectClass=user)(cn=*))" + ldapUserFilterObjectclass: "user" + ldapLoginFilter: "(&(objectClass=user)(cn=%uid))" + ldapLoginFilterUsername: "1" + ldapUserDisplayName: "cn" + ldapEmailAttribute: "mail" + ldapExpertUsernameAttr: "cn" + ldapExpertUUIDUserAttr: "uid" + ldapExpertUUIDGroupAttr: "uid" + ldapBaseGroups: "ou=groups,dc=phbe,dc=souveredu,dc=ch" + ldapGroupFilter: "(&(objectClass=group))" + ldapGroupFilterObjectclass: "group" + ldapGroupDisplayName: "cn" + ldapGroupMemberAssocAttr: "member" + ldapAdminGroup: "admins" + ldapCacheTTL: "600" + ldapPagingSize: "500" + ldapExperiencedAdmin: "1" + ldapConfigurationActive: "1" + +# OIDC providers for login (Authentik) +nextcloud_oidc_providers: + - identifier: authentik + display_name: "Login with Authentik" + client_id: nextcloud + client_secret: "{{ _authentik.nextcloud_oidc_secret }}" + # 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.phbe.souveredu.ch/application/o/nextcloud/.well-known/openid-configuration" + scope: "openid email profile" + unique_uid: true + mapping: + uid: preferred_username + display_name: name + email: email diff --git a/inventories/demo-phbern/host_vars/application/opnform.yml b/inventories/demo-phbern/host_vars/application/opnform.yml new file mode 100644 index 0000000..b1364b4 --- /dev/null +++ b/inventories/demo-phbern/host_vars/application/opnform.yml @@ -0,0 +1,50 @@ +--- +# Bao secret /data/opnform expected to contain: +# app_key (must start with "base64:"), jwt_secret, front_api_secret, +# db_password, admin_password, oidc_client_secret +_opnform: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/opnform', url=vault_addr) }}" +_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" + +opnform_domain: "forms.phbe.souveredu.ch" +opnform_extra_domains: + - "forms.int.phbe.souveredu.ch" +opnform_base_url: "https://forms.phbe.souveredu.ch" + +opnform_app_key: "{{ _opnform.app_key }}" +opnform_jwt_secret: "{{ _opnform.jwt_secret }}" +opnform_front_api_secret: "{{ _opnform.front_api_secret }}" +opnform_db_password: "{{ _opnform.db_password }}" + +# Bootstrap admin via API on first run so the manual setup page is +# skipped. The admin credentials are also required to seed the OIDC +# IdentityConnection through OpnForm's API (only an authenticated admin +# can create connections). +opnform_admin_name: "OpnForm Admin" +opnform_admin_email: "admin@phbe.souveredu.ch" +opnform_admin_password: "{{ _opnform.admin_password }}" + +# OIDC against Authentik. The public auth.phbe.* 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 FQDN: OpnForm validates the token's `iss` +# claim against this value, and Authentik emits the public hostname there. +opnform_oidc_issuer: "https://auth.phbe.souveredu.ch/application/o/opnform/" +opnform_oidc_client_id: "opnform" +opnform_oidc_client_secret: "{{ _opnform.oidc_client_secret }}" +opnform_oidc_client_name: "Authentik" +opnform_oidc_slug: "authentik" +opnform_oidc_domain: "phbe.souveredu.ch" +opnform_oidc_admin_group: "opnform-admins" + +# Disable password login entirely — every user goes through Authentik. +# All real users have @phbe.souveredu.ch addresses (matching +# opnform_oidc_domain above), so no password fallback is needed. +opnform_oidc_force_login: true + +# `/` and `/login` are intercepted and jump straight to Authentik. +# Public form deep-links (`/forms/`, `/admin/...`) keep working. +# Break-glass: /login?bypass=1 reaches the email form when the IdP is +# down. +opnform_oidc_sso_entrypoint: true diff --git a/inventories/demo-phbern/host_vars/application/send.yml b/inventories/demo-phbern/host_vars/application/send.yml new file mode 100644 index 0000000..3009b36 --- /dev/null +++ b/inventories/demo-phbern/host_vars/application/send.yml @@ -0,0 +1,8 @@ +--- +# Send: anonymized self-hosted file-share (no login). First entry is the +# canonical public FQDN (used as BASE_URL); the *.int.* entry covers the +# server-to-server hop from the DMZ reverseproxy with a cert SAN that +# matches the backend hostname (same split-horizon pattern as cloud/draw). +send_domains: + - "send.phbe.souveredu.ch" + - "send.int.phbe.souveredu.ch" diff --git a/inventories/demo-phbern/host_vars/application/traefik.yml b/inventories/demo-phbern/host_vars/application/traefik.yml new file mode 100644 index 0000000..3dec920 --- /dev/null +++ b/inventories/demo-phbern/host_vars/application/traefik.yml @@ -0,0 +1,46 @@ +--- +# Services hosted on `application` that the DMZ reverseproxy should +# forward public traffic to. The DMZ traefik picks this up via +# hostvars[backend].traefik_dmz_exposed_services and renders a router + +# service for each entry into /config/services.yml. +traefik_dmz_exposed_services: + - name: authentik + domain: auth.phbe.souveredu.ch + backend_host: auth.int.phbe.souveredu.ch + port: 443 + protocol: https + - name: nextcloud + domain: cloud.phbe.souveredu.ch + backend_host: cloud.int.phbe.souveredu.ch + port: 443 + protocol: https + - name: collabora + domain: office.phbe.souveredu.ch + backend_host: office.int.phbe.souveredu.ch + port: 443 + protocol: https + - name: drawio + domain: draw.phbe.souveredu.ch + backend_host: draw.int.phbe.souveredu.ch + port: 443 + protocol: https + - name: send + domain: send.phbe.souveredu.ch + backend_host: send.int.phbe.souveredu.ch + port: 443 + protocol: https + - name: opnform + domain: forms.phbe.souveredu.ch + backend_host: forms.int.phbe.souveredu.ch + port: 443 + protocol: https + - name: homarr + domain: home.phbe.souveredu.ch + backend_host: home.int.phbe.souveredu.ch + port: 443 + protocol: https + - name: bookstack + domain: wiki.phbe.souveredu.ch + backend_host: wiki.int.phbe.souveredu.ch + port: 443 + protocol: https diff --git a/inventories/demo-phbern/host_vars/reverseproxy/traefik.yml b/inventories/demo-phbern/host_vars/reverseproxy/traefik.yml new file mode 100644 index 0000000..48f6d08 --- /dev/null +++ b/inventories/demo-phbern/host_vars/reverseproxy/traefik.yml @@ -0,0 +1,22 @@ +--- +traefik_mode: dmz + +# The DMZ traefik discovers which services to expose by reading +# traefik_dmz_exposed_services from each backend host's host_vars +# (application/traefik.yml, storage/traefik.yml). See the role's +# tasks/main.yml — set_fact "Build service registry from backend +# servers (DMZ mode)". + +# From the DMZ network the public ns1 IP (193.43.183.169) is not +# reachable on port 53, but the internal address (172.16.9.169) is. +# Override the group-level traefik_acme_dns_nameserver from bao so +# lego's RFC2136 updates land at the internal interface. The TSIG +# key/secret are the same; only the transport target changes. +traefik_acme_dns_nameserver: "172.16.9.169" + +# Lego's propagation check normally polls the NS hostnames listed in +# the zone's SOA (ns1.digitalboard.ch.) — which resolves to the +# public IP that's unreachable from this DMZ host. Skip that check; +# lego still polls via the resolver above before asking LE to +# validate. +traefik_acme_disable_ans_checks: true diff --git a/inventories/demo-phbern/host_vars/storage/authentik_outpost_proxy.yml b/inventories/demo-phbern/host_vars/storage/authentik_outpost_proxy.yml new file mode 100644 index 0000000..1f17fbd --- /dev/null +++ b/inventories/demo-phbern/host_vars/storage/authentik_outpost_proxy.yml @@ -0,0 +1,19 @@ +--- +# Proxy (ForwardAuth) outpost co-located with garage on storage. The +# garage-webui Traefik router's ForwardAuth middleware points at this +# container over the local `proxy` docker network, so the auth subrequest +# never leaves storage — the embedded outpost on the application host +# can't be used here because the cross-host hop mangles X-Forwarded-Host. +_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" + +# Outpost pulls its config from authentik over the public FQDN (resolves +# via the internal DNS view to the DMZ reverseproxy the storage subnet can +# reach). Token must match the storage-proxy-outpost registered in +# authentik (see application/authentik.yml authentik_proxy_outposts). +# Verify the authentik TLS chain: auth.phbe.* presents a valid Let's +# Encrypt cert via the reverseproxy and the storage subnet reaches it, so +# there's no reason to disable verification (verified: ssl_verify_result=0). +authentik_outpost_proxy_host: "https://auth.phbe.souveredu.ch" +authentik_outpost_proxy_token: "{{ _authentik.proxy_outpost_token }}" +authentik_outpost_proxy_insecure: "false" +authentik_outpost_proxy_network: "proxy" diff --git a/inventories/demo-phbern/host_vars/storage/garage.yml b/inventories/demo-phbern/host_vars/storage/garage.yml new file mode 100644 index 0000000..4e7c2d7 --- /dev/null +++ b/inventories/demo-phbern/host_vars/storage/garage.yml @@ -0,0 +1,51 @@ +--- +# Bao secret /data/garage expected to contain: +# rpc_secret, admin_token, metrics_token, webui_password +_garage: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/garage', url=vault_addr) }}" + +# First entry is the canonical public S3 FQDN. Additional entries +# cover internal *.int.* names so server-to-server S3 traffic (e.g. +# nextcloud → garage) stays in the LAN. +garage_s3_domains: + - "s3.phbe.souveredu.ch" + - "s3.int.phbe.souveredu.ch" +# First entry is the canonical public console FQDN. The *.int.* entry +# gives the DMZ a backend_host whose cert SAN matches (storage/traefik.yml), +# so the DMZ->storage hop verifies TLS instead of failing on a raw IP. +garage_webui_domains: + - "console.s3.phbe.souveredu.ch" + - "console.s3.int.phbe.souveredu.ch" +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. +# +# ForwardAuth talks to the storage-local proxy outpost over the docker +# network (role digitalboard.core.authentik_outpost_proxy). The embedded +# outpost on the application host can't be used cross-host: the extra +# reverseproxy hop mangles X-Forwarded-Host (appends :443 / a comma list) +# and the outpost then 404s instead of matching console.s3.* against the +# provider's external_host. A co-located outpost keeps the subrequest on +# the local network with a clean X-Forwarded-Host. +garage_webui_authentik_forward_auth: true +garage_webui_authentik_forward_auth_url: "http://authentik-outpost-proxy-proxy-1:9000/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') }}" + +garage_rpc_secret: "{{ _garage.rpc_secret }}" +garage_admin_token: "{{ _garage.admin_token }}" +garage_metrics_token: "{{ _garage.metrics_token }}" + +# Initial cluster bootstrap (single-node) +garage_bootstrap_enabled: true +garage_bootstrap_zone: "bern1" +garage_bootstrap_capacity: "100G" + +# Buckets and keys consumed by nextcloud +garage_s3_keys: + - name: nextcloud + buckets: + - name: nextcloud + permissions: ["read", "write"] diff --git a/inventories/demo-phbern/host_vars/storage/main.yml b/inventories/demo-phbern/host_vars/storage/main.yml new file mode 100644 index 0000000..75e487d --- /dev/null +++ b/inventories/demo-phbern/host_vars/storage/main.yml @@ -0,0 +1,2 @@ +--- +# storage host runs: garage (S3 + WebUI) diff --git a/inventories/demo-phbern/host_vars/storage/traefik.yml b/inventories/demo-phbern/host_vars/storage/traefik.yml new file mode 100644 index 0000000..96c6110 --- /dev/null +++ b/inventories/demo-phbern/host_vars/storage/traefik.yml @@ -0,0 +1,21 @@ +--- +# Services hosted on `storage` that the DMZ reverseproxy should forward +# public traffic to. See application/traefik.yml for the mechanism. +# The garage-webui ForwardAuth no longer needs an auth FQDN pinned here — +# it talks to the storage-local proxy outpost over the docker network +# (see garage.yml), and the outpost reaches authentik via the public FQDN +# over the reverseproxy (firewall now permits backend -> DMZ). +traefik_dmz_exposed_services: + - name: garage-s3 + domain: s3.phbe.souveredu.ch + backend_host: s3.int.phbe.souveredu.ch + port: 443 + protocol: https + - name: garage-webui + domain: console.s3.phbe.souveredu.ch + # Internal name so the DMZ verifies the storage backend cert against a + # matching SAN (acme cert_mode, no insecureSkipVerify). garage now + # serves this SAN via garage_webui_domains (see garage.yml). + backend_host: console.s3.int.phbe.souveredu.ch + port: 443 + protocol: https diff --git a/inventories/demo-phbern/hosts.yml b/inventories/demo-phbern/hosts.yml index 1dafcf5..6a264be 100644 --- a/inventories/demo-phbern/hosts.yml +++ b/inventories/demo-phbern/hosts.yml @@ -22,6 +22,7 @@ all: backend_servers: hosts: application: + storage: garage_servers: hosts: @@ -31,10 +32,6 @@ all: hosts: application: - opencloud_servers: - hosts: - application: - collabora_servers: hosts: application: @@ -45,4 +42,32 @@ all: authentik_servers: hosts: - application: \ No newline at end of file + application: + + authentik_outpost_ldap_servers: + hosts: + application: + + # Proxy (ForwardAuth) outpost co-located with garage on storage, so + # the garage-webui ForwardAuth subrequest stays on the local docker + # network instead of crossing an extra reverseproxy hop (which mangles + # X-Forwarded-Host and breaks the embedded outpost's app matching). + authentik_outpost_proxy_servers: + hosts: + storage: + + send_servers: + hosts: + application: + + opnform_servers: + hosts: + application: + + homarr_servers: + hosts: + application: + + bookstack_servers: + hosts: + application: -- 2.49.1