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] =?UTF-8?q?feat(demo):=20add=20full=20inventories=20for=20?= =?UTF-8?q?mbaz=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: