docs(reference-ansible): add docs/ tree and document repo, playbooks, Makefile

Addresses the WKS PoC review (Notion 2026-05-26). All docs in English.
- README: purpose, docs table of contents, annotated repo tree
- docs/getting_started.md: prerequisites (WKS account, OIDC, SSH, VPN) + first deploy
- docs/ansible.md: playbook table, "Running Ansible", service parameters, cheatsheet
- docs/secrets.md: canonical Bao login (moved out of README) + demo defaults
- docs/operations.md: full Makefile reference
- docs/inventories.md: repo layout, topology, standard folder structure, walkthrough
- docs/testing.md: static checks, inventory resolution, smoke test / dry run
- remove ARCHITECTURE.md (architecture docs live externally)

Also includes the gymburgdorf inventory build-out (bookstack, homarr,
opnform, send) and scripts/bao-seed.sh. site.yml keeps a third traefik
play (traefik_servers minus the vagrant _dmz/_backend split) so the demo
inventories still configure their reverse proxy after the rebase onto main.
This commit is contained in:
Simon Bärlocher 2026-05-27 18:08:52 +02:00
parent c67e9aac43
commit 2ba0c07cd3
No known key found for this signature in database
GPG key ID: 63DE20495932047A
24 changed files with 1541 additions and 525 deletions

View file

@ -2,15 +2,21 @@
# Bao secret expected at <mount>/data/authentik with keys:
# secret_key, postgres_password, admin_password,
# ldap_outpost_token,
# nextcloud_oidc_secret
# 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. Additional entries cover
# internal *.int.* names so server-to-server traffic (e.g. the LDAP
# outpost) hits authentik on a name with a valid internal cert and
# skips the DMZ hop.
# Canonical public FQDN browsers and OIDC iss-claim use.
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 }}"
@ -31,6 +37,44 @@ authentik_ldap_outpost:
authentik_host: "https://auth.int.gymb.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.gymb.souveredu.ch"
internal_host: "http://drawio:8080"
allowed_groups:
- admins
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.gymb.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
# 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.
authentik_proxy_outposts:
- name: "authentik Embedded Outpost"
type: proxy
providers:
- drawio
- garage-webui
# OIDC clients
authentik_oidc_apps:
- slug: nextcloud
@ -45,10 +89,52 @@ authentik_oidc_apps:
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.gymb.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.gymb.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.gymb.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

View file

@ -0,0 +1,43 @@
---
# Bao secret <mount>/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.gymb.souveredu.ch"
bookstack_extra_domains:
- "wiki.int.gymb.souveredu.ch"
bookstack_base_url: "https://wiki.gymb.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@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.
bookstack_oidc_enabled: true
bookstack_oidc_name: "Authentik"
bookstack_oidc_issuer: "https://auth.gymb.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
# 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"

View file

@ -1,2 +1,19 @@
---
drawio_domain: "draw.gymb.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.gymb.souveredu.ch"
# Gate drawio behind the authentik embedded outpost (admins-only —
# enforced by the policy-binding on the authentik proxy application).
# 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"

View file

@ -0,0 +1,71 @@
---
# Bao secret <mount>/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.gymb.souveredu.ch"
homarr_extra_domains:
- "home.int.gymb.souveredu.ch"
homarr_base_url: "https://home.gymb.souveredu.ch"
homarr_secret_encryption_key: "{{ _homarr.secret_encryption_key }}"
homarr_admin_username: "admin"
homarr_admin_email: "admin@gymb.souveredu.ch"
homarr_admin_password: "{{ _homarr.admin_password }}"
# OIDC against Authentik. credentials provider stays enabled as a
# break-glass account.
homarr_auth_providers: "credentials,oidc"
homarr_oidc_issuer: "https://auth.int.gymb.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_admin_group: "homarr-admins"
homarr_oidc_auto_login: "false"
# 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).
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.gymb.souveredu.ch
width: 2
- id: collabora
name: Collabora Office
icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/collaboraonline.png
href: https://office.gymb.souveredu.ch
width: 2
- id: drawio
name: Draw.io
icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/drawio.png
href: https://draw.gymb.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.gymb.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.gymb.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.gymb.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.gymb.souveredu.ch
width: 2

View file

@ -4,6 +4,11 @@
_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) }}"
# 33.0.2 hits the PHP 8.4 TypeError in UserConfig::getValueBool() that
# user_ldap triggers on every authenticated request (nextcloud/server
# #59629; fix in 33.0.3). Pin to the patched tag.
nextcloud_image: "nextcloud:33.0.3-fpm"
# 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
@ -57,10 +62,25 @@ 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".
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:
@ -98,12 +118,13 @@ nextcloud_oidc_providers:
display_name: "Login with Authentik"
client_id: nextcloud
client_secret: "{{ _authentik.nextcloud_oidc_secret }}"
# Stays on the public FQDN: user_oidc validates the iss claim against
# the discovery host, and authentik returns iss based on the request
# host — using auth.int.* would break the iss match with what the
# browser sees (auth.gymb.*). Routed via the DMZ for now; revisit if
# this becomes a bottleneck.
discovery_url: "https://auth.gymb.souveredu.ch/application/o/nextcloud/.well-known/openid-configuration"
# 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"
scope: "openid email profile"
unique_uid: true
mapping:

View file

@ -0,0 +1,60 @@
---
# Bao secret <mount>/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.gymb.souveredu.ch"
opnform_extra_domains:
- "forms.int.gymb.souveredu.ch"
opnform_base_url: "https://forms.gymb.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@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.
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.
opnform_oidc_issuer: "https://auth.gymb.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: "gymb.souveredu.ch"
opnform_oidc_admin_group: "opnform-admins"
# Disable password login entirely — every user goes through Authentik.
# All real users have @gymb.souveredu.ch addresses (matching
# opnform_oidc_domain above), so no password fallback is needed.
opnform_oidc_force_login: true
# Serve a /sso page that jumps straight to Authentik without the email
# login form. Link users to https://forms.gymb.souveredu.ch/sso.
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"

View file

@ -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.gymb.souveredu.ch"
- "send.int.gymb.souveredu.ch"

View file

@ -21,9 +21,26 @@ traefik_dmz_exposed_services:
protocol: https
- name: drawio
domain: draw.gymb.souveredu.ch
# No internal FQDN/cert for drawio yet — proxy by IP. Combined
# with serversTransport `insecureSkipVerify` (handled by the
# selfsigned-mode branch in the template), or accept the route's
# 500 until the cert is wired up.
backend_host: draw.int.gymb.souveredu.ch
port: 443
protocol: https
- name: send
domain: send.gymb.souveredu.ch
backend_host: send.int.gymb.souveredu.ch
port: 443
protocol: https
- name: opnform
domain: forms.gymb.souveredu.ch
backend_host: forms.int.gymb.souveredu.ch
port: 443
protocol: https
- name: homarr
domain: home.gymb.souveredu.ch
backend_host: home.int.gymb.souveredu.ch
port: 443
protocol: https
- name: bookstack
domain: wiki.gymb.souveredu.ch
backend_host: wiki.int.gymb.souveredu.ch
port: 443
protocol: https

View file

@ -12,8 +12,18 @@ garage_s3_domains:
garage_webui_domain: "console.s3.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 forwardauth URL
# resolves to the application-host traefik (network alias
# `auth.gymb.souveredu.ch` -> authentik-server-1 in the proxy network on
# the application host), but THIS host (storage) is in a different LAN,
# so traefik here reaches it via the public name through the DMZ proxy.
garage_webui_authentik_forward_auth: true
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 }}"
garage_webui_password: "{{ _garage.webui_password | default('disabled') }}"
garage_rpc_secret: "{{ _garage.rpc_secret }}"
garage_admin_token: "{{ _garage.admin_token }}"

View file

@ -1,4 +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 point auth.gymb.* directly at the
# application host where authentik runs. Without this the forwardauth
# middleware would time out and every garage-console request would 502.
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.
traefik_dmz_exposed_services:

View file

@ -45,5 +45,21 @@ all:
application:
authentik_outpost_ldap_servers:
hosts:
application:
send_servers:
hosts:
application:
opnform_servers:
hosts:
application:
homarr_servers:
hosts:
application:
bookstack_servers:
hosts:
application: