diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 807f408..a98825c 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,49 +1,151 @@
-
-# Architekturskizze — `demo-gymburgdorf`
+
+# Architektur — `reference-ansible`
-Diese Skizze zeigt, wie das `reference-ansible`-Repo am Beispiel der
-Inventory `demo-gymburgdorf` funktioniert: welche Hosts existieren,
-welche Rollen darauf laufen, wo welche Variablen hingehören und wie
-Secrets aus OpenBao gelookupt werden.
+Dieses Dokument beschreibt die Architektur des Repos `reference-ansible`
+und nutzt die Inventory `inventories/demo-gymburgdorf/` als
+durchgehendes Beispiel. Es dient sowohl als Onboarding-Doku für neue
+Engineers als auch als Referenz beim Aufsetzen weiterer Demo-Mandanten.
-## 1. Variablen-Hierarchie (Ansible Precedence)
+> **Demo-only.** Alle Defaults in den Rollen (Passwörter, Tokens,
+> RPC-Secrets) sind unsicher und ausschliesslich für Demo-Setups
+> gedacht. Siehe [§ 8 — Security und Demo-Only-Defaults](#8-security-und-demo-only-defaults).
-```mermaid
-flowchart TB
- classDef rolelayer fill:#fef3c7,stroke:#92400e,color:#000
- classDef grouplayer fill:#dbeafe,stroke:#1e40af,color:#000
- classDef hostlayer fill:#dcfce7,stroke:#166534,color:#000
- classDef vaultlayer fill:#fee2e2,stroke:#991b1b,color:#000
+**Letzte Aktualisierung:** 2026-05-18 · **Owner:** @sbaerlocher
- R["role defaults/main.yml
(niedrigste Precedence)
~180 Variablen über 12 Rollen
z.B. traefik_use_ssl: false
keycloak_admin_password: changeme"]:::rolelayer
+## Inhalt
- GA["group_vars/all/
docker.yml → docker_registry_mirrors
vault.yml → vault_addr, vault_mount"]:::grouplayer
+- [§ 0 — Glossar](#0-glossar)
+- [§ 1 — Repo-Layout und Roles-Herkunft](#1-repo-layout-und-roles-herkunft)
+- [§ 2 — Setup und Voraussetzungen](#2-setup-und-voraussetzungen)
+- [§ 3 — Variablen-Hierarchie](#3-variablen-hierarchie)
+- [§ 4 — Inventory-Topologie (`demo-gymburgdorf`)](#4-inventory-topologie-demo-gymburgdorf)
+- [§ 5 — Service-Layout und Variablen-Verortung](#5-service-layout-und-variablen-verortung)
+- [§ 6 — Deploy-Flow](#6-deploy-flow)
+- [§ 7 — Traefik-Modi (DMZ vs Backend)](#7-traefik-modi-dmz-vs-backend)
+- [§ 8 — Security und Demo-Only-Defaults](#8-security-und-demo-only-defaults)
+- [§ 9 — Variablen-Cheatsheet](#9-variablen-cheatsheet)
+- [§ 10 — Walkthrough: Neuen Demo-Mandanten anlegen](#10-walkthrough-neuen-demo-mandanten-anlegen)
+- [§ 11 — Bekannte Lücken und Trade-offs](#11-bekannte-lücken-und-trade-offs)
- GT["group_vars/traefik_servers/
traefik.yml
traefik_use_ssl, traefik_cert_mode: acme
traefik_acme_dns_zone
traefik_acme_tsig_* (Vault-Lookup!)"]:::grouplayer
+## 0. Glossar
- GB["group_vars/backend_servers/
traefik.yml → traefik_mode: backend"]:::grouplayer
+| Begriff | Bedeutung |
+|---|---|
+| **OpenBao** | HashiCorp-Vault-Fork. Single Source of Truth für Secrets. Endpoint: `bao.digitalboard.ch`. |
+| **Authentik** | Identity Provider. Stellt OIDC für SP-Services und LDAP via Outpost. |
+| **Outpost (Authentik)** | Separater Authentik-Sidecar, der LDAP/Proxy-Protokolle für Legacy-Apps emuliert. Spricht via RPC + Token zu Authentik. |
+| **WOPI** | Web Application Open Platform Interface — Protokoll, mit dem Nextcloud/Opencloud Office-Dokumente an Collabora übergeben. |
+| **TSIG / RFC2136** | Authenticated DNS-Updates. Traefik nutzt TSIG-signierte `nsupdate`-Calls für ACME DNS-01-Challenges. |
+| **DNS-01 (ACME)** | Let's-Encrypt-Challenge-Typ: Zertifikatsbesitz wird per TXT-Record im DNS bewiesen statt per HTTP. Erforderlich für Wildcard-Certs. |
+| **CNAME-Bridge** | `_acme-challenge.` zeigt per CNAME in eine dedizierte Update-Zone (`demo-gymb._acme.digitalboard.ch`). So bleibt der TSIG-Key auf eine schmale Zone beschränkt. |
+| **File-Provider / Docker-Provider** | Traefik-Konfigurationsquellen. File-Provider liest statische YAML, Docker-Provider liest Container-Labels via `/var/run/docker.sock`. |
+| **STUN/TURN** | NAT-Traversal-Protokolle für WebRTC (z. B. für Nextcloud Talk). Läuft auf separatem Host (`turn`). |
+| **Garage** | S3-kompatibler Object Store (Rust). Backend für Nextcloud/Opencloud. |
+| **FQCN** | Fully Qualified Collection Name, z. B. `digitalboard.core.traefik`. Ansible-Pflicht ab 2.10. |
- HR["host_vars/reverseproxy/
traefik.yml → traefik_mode: dmz"]:::hostlayer
+## 1. Repo-Layout und Roles-Herkunft
- HA["host_vars/application/ (fehlt aktuell!)
FQDNs, OIDC-Clients, DB-Passwords
nextcloud_domain, authentik_domain, ..."]:::hostlayer
-
- HS["host_vars/storage/ (fehlt aktuell!)
garage_s3_domain, garage_*_token
traefik_dmz_exposed_services (für DMZ)"]:::hostlayer
-
- V["HashiCorp Vault / OpenBao
bao.digitalboard.ch
mount: demo-gymburgdorf
z.B. demo-gymburgdorf/data/acme-tsig"]:::vaultlayer
-
- R --> GA --> GT --> GB --> HR
- GB --> HA
- GB --> HS
- GT -.Lookup zur Laufzeit.-> V
- HA -.Lookup zur Laufzeit.-> V
- HS -.Lookup zur Laufzeit.-> V
+```text
+reference-ansible/
+├── Makefile # Deploy-Targets, OIDC-Login, OBJC-Fork-Workaround
+├── ansible.cfg # collections_path, remote_user=root, hashi_vault auth_method=token
+├── requirements.yml # community.hashi_vault + digitalboard.core (Git)
+├── playbooks/site.yml # Play-Sequenz (14 Plays, siehe § 6)
+├── collections/ # ← installiert von `make install`, gitignored
+│ └── ansible_collections/
+│ └── digitalboard/core/
+│ └── roles/ # 🔑 HIER liegen die Rollen, NICHT im Repo-Root
+└── inventories/
+ ├── demo-gymburgdorf/ # Inventory dieses Dokuments
+ ├── demo-mbazürich/
+ ├── demo-phbern/
+ └── vagrant/ # lokale Test-Inventory mit eigener Topologie
```
-Höhere Ebene überschreibt tiefere. `host_vars/reverseproxy/traefik_mode: dmz`
-schlägt also `group_vars/backend_servers/traefik_mode: backend` —
-möglich, weil `reverseproxy` *nicht* in `backend_servers` ist.
+> **Wichtig:** Es gibt **kein** `roles/`-Verzeichnis im Repo-Root. Alle
+> Rollen kommen aus der Collection `digitalboard.core` (siehe
+> [requirements.yml](requirements.yml)), installiert via `make install`
+> nach `./collections/`. Plays referenzieren sie per FQCN
+> `digitalboard.core.`.
-## 2. Inventory-Topologie demo-gymburgdorf
+## 2. Setup und Voraussetzungen
+
+**Tools auf dem Control-Node:**
+
+- `ansible` (Core ≥ 2.15)
+- `bao` CLI (OpenBao) — z. B. `sudo pacman -S openbao python-hvac` (Arch) oder Homebrew
+- `python-hvac` (für `community.hashi_vault` Lookups)
+- Auf macOS: `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` (im [Makefile](Makefile) gesetzt; ohne crashen Ansible-Forks beim Bao-Lookup)
+
+**Initial-Setup:**
+
+```bash
+git clone
+cd reference-ansible
+make install # Galaxy + digitalboard.core nach ./collections/
+```
+
+**Vor jedem Deploy:** Bao-Login in **derselben Shell**, in der dann `ansible-playbook` läuft:
+
+```bash
+export BAO_ADDR=https://bao.digitalboard.ch
+bao login -method=oidc -path=Digitalboard
+export VAULT_TOKEN=$(bao print token)
+```
+
+> ⚠️ `make bao` allein reicht **nicht**: jedes `make`-Target startet
+> eine neue Shell, der dort gesetzte `VAULT_TOKEN` lebt nur
+> während `make bao` selbst. Entweder die drei Befehle oben manuell
+> ausführen oder `make bao deploy_site_demo_gymburgdorf` als **einen**
+> Aufruf — sonst hat das Deploy keinen Token.
+
+**Smoke-Test:**
+
+```bash
+make ping_demo # pingt alle drei Demo-Inventories
+```
+
+## 3. Variablen-Hierarchie
+
+Ansible mergt Variablen über mehrere Quellen. Vereinfachtes Modell für
+dieses Repo (vollständige Precedence siehe Ansible-Docs):
+
+```mermaid
+flowchart LR
+ classDef role fill:#fef3c7,stroke:#92400e,color:#000
+ classDef group fill:#dbeafe,stroke:#1e40af,color:#000
+ classDef host fill:#dcfce7,stroke:#166534,color:#000
+ classDef vault fill:#fee2e2,stroke:#991b1b,color:#000
+
+ R["role defaults
(niedrigste Precedence)
collections/.../roles/<r>/defaults/main.yml"]:::role
+ GA["group_vars/all/
vault.yml, docker.yml"]:::group
+ GG["group_vars/<group>/
traefik_servers/, backend_servers/
(parallele Gruppen, gemerged via
ansible_group_priority)"]:::group
+ HV["host_vars/<host>/
(höchste der drei Inventory-Quellen)"]:::host
+ BAO["OpenBao
Lookup zur Laufzeit"]:::vault
+
+ R --> |"<wird überschrieben von>"| GA
+ GA --> |"<wird überschrieben von>"| GG
+ GG --> |"<wird überschrieben von>"| HV
+ HV -.community.hashi_vault.-> BAO
+ GG -.community.hashi_vault.-> BAO
+```
+
+**Wichtige Eigenschaften:**
+
+- Mehrere `group_vars//` sind **parallel**, nicht hierarchisch
+ geschachtelt. `traefik_servers` und `backend_servers` werden nach
+ `ansible_group_priority` (Default 1) gemerged; bei Konflikt gewinnt
+ alphabetisch der spätere Gruppenname.
+- `host_vars//` schlägt jede Gruppe.
+- `host_vars/reverseproxy/traefik.yml: traefik_mode: dmz` überschreibt
+ daher den Default aus `group_vars/backend_servers/` — und zwar nur,
+ weil `reverseproxy` nicht Mitglied von `backend_servers` ist (sonst
+ ginge es sowieso nicht).
+
+**Bao-Lookups** sind keine Precedence-Ebene, sondern **Werte** innerhalb
+einer beliebigen Var-Quelle. Pattern siehe [§ 8](#8-security-und-demo-only-defaults).
+
+## 4. Inventory-Topologie (`demo-gymburgdorf`)
```mermaid
flowchart LR
@@ -52,33 +154,40 @@ flowchart LR
classDef stor fill:#dbeafe,stroke:#1e40af,color:#000
classDef turn fill:#fef9c3,stroke:#854d0e,color:#000
- subgraph ALL["group: all_servers (alle Hosts)"]
+ subgraph ALL["group: all_servers"]
direction LR
- subgraph DMZ["DMZ-Segment 172.16.9.0/24"]
+ subgraph DMZ["DMZ 172.16.9.0/24"]
RP["reverseproxy
172.16.9.111
traefik_mode: dmz"]:::dmz
- TURN["turn
172.16.9.112
(STUN/TURN)"]:::turn
+ TURN["turn
172.16.9.112
(noch keine Rolle in site.yml)"]:::turn
end
- subgraph BE["Backend-Segment 172.16.19.0/24
(group: backend_servers)"]
- APP["application
172.16.19.101
traefik_mode: backend
+ nextcloud, opencloud,
collabora, drawio,
authentik, authentik_outpost_ldap"]:::app
+ subgraph BE["Backend 172.16.19.0/24
group: backend_servers"]
+ APP["application
172.16.19.101
traefik_mode: backend
+ authentik, authentik_outpost_ldap,
nextcloud, collabora, drawio"]:::app
ST["storage
172.16.19.102
traefik_mode: backend
+ garage (S3)"]:::stor
end
end
- RP -.HTTP/HTTPS reverse proxy.-> APP
- RP -.HTTP/HTTPS reverse proxy.-> ST
+ RP -.HTTPS in, HTTP out.-> APP
+ RP -.HTTPS in, HTTP out.-> ST
```
-Gruppen-Mitgliedschaften (`hosts.yml`):
+**Gruppen-Mitgliedschaften (aus [hosts.yml](inventories/demo-gymburgdorf/hosts.yml)):**
-- `traefik_servers` ⊇ `all_servers` → **alle 4 Hosts** bekommen Traefik
- (DMZ-Modus für `reverseproxy`, Backend-Modus für `application`/`storage`).
-- `backend_servers = {application, storage}` → setzt
- `traefik_mode: backend` via group_vars.
-- Service-Gruppen (`nextcloud_servers`, `garage_servers`, …) sind
- Single-Host-Wrapper, mit denen `playbooks/site.yml` gezielt
- deploybare Rollen targetet.
+| Gruppe | Mitglieder | Zweck |
+|---|---|---|
+| `all_servers` | `reverseproxy`, `application`, `storage`, `turn` | Basis-Rolle für alle Hosts |
+| `traefik_servers` | `children: all_servers` (= alle 4 Hosts) | Traefik überall; DMZ/Backend per `traefik_mode` |
+| `backend_servers` | `application`, `storage` | setzt `traefik_mode: backend` per group_var |
+| `garage_servers` | `storage` | Single-Host-Wrapper für Garage-Role |
+| `nextcloud_servers`, `collabora_servers`, `drawio_servers`, `authentik_servers`, `authentik_outpost_ldap_servers` | je nur `application` | Single-Host-Wrapper |
-## 3. Service-Layout & Variablen-Verortung
+> **Unterschied zur `vagrant`-Inventory:** `vagrant` strukturiert
+> Traefik anders — über die Children-Gruppen `traefik_servers_dmz` und
+> `traefik_servers_backend` statt über `backend_servers` +
+> `host_vars`-Override. Die beiden Topologien sind **strukturell
+> inkompatibel**; ein 1:1-Mapping geht nicht. Siehe [§ 10](#10-walkthrough-neuen-demo-mandanten-anlegen)
+> für die empfohlene Vorlage.
+
+## 5. Service-Layout und Variablen-Verortung
```mermaid
flowchart TB
@@ -88,120 +197,257 @@ flowchart TB
classDef ext fill:#e9d5ff,stroke:#6b21a8,color:#000
Internet((Internet))
- DNS["DNS ns1.digitalboard.ch
RFC2136 TSIG (key: acme_update_key_demo_gymb)
dynamic zone: demo-gymb._acme.digitalboard.ch
CNAME-bridge: _acme-challenge.*.gymb.souveredu.ch"]:::ext
+ DNS["DNS ns1.digitalboard.ch
RFC2136 TSIG
Zone: demo-gymb._acme.digitalboard.ch
CNAME-Bridge: _acme-challenge.*.gymb.souveredu.ch"]:::ext
BAO["OpenBao
bao.digitalboard.ch
mount: demo-gymburgdorf"]:::ext
subgraph RP["reverseproxy — traefik dmz"]
- TRDMZ["traefik (file provider)
📍 group_vars/traefik_servers/traefik.yml
→ acme, tsig, ssl
📍 host_vars/reverseproxy/traefik.yml
→ traefik_mode: dmz
📍 host_vars/reverseproxy/...
→ traefik_dmz_exposed_services"]:::rp
+ TRDMZ["traefik (file provider)
📍 group_vars/traefik_servers/traefik.yml
📍 host_vars/reverseproxy/traefik.yml
→ traefik_mode: dmz
→ traefik_dmz_exposed_services"]:::rp
end
- subgraph APP["application — backend"]
- TRA["traefik (docker provider)
📍 group_vars/backend_servers
→ traefik_mode: backend"]:::ap
- NC["nextcloud
📍 host_vars/application/nextcloud.yml
domain, postgres_pw, oidc, s3, ldap"]:::ap
- OC["opencloud
📍 host_vars/application/opencloud.yml
oidc_issuer, ldap, s3"]:::ap
- AK["authentik
📍 host_vars/application/authentik.yml
secret_key, postgres, ldap_apps, oidc_apps"]:::ap
- AKO["authentik_outpost_ldap
📍 host_vars/application/authentik_outpost_ldap.yml
host, token"]:::ap
- COL["collabora
📍 host_vars/application/collabora.yml
domain, allowed_domains"]:::ap
+ subgraph APP["application — traefik backend"]
+ TRA["traefik (docker provider)
📍 group_vars/backend_servers/traefik.yml"]:::ap
+ AK["authentik (OIDC + LDAP-Outpost-Backend)
📍 host_vars/application/authentik.yml"]:::ap
+ AKO["authentik_outpost_ldap
📍 host_vars/application/authentik_outpost_ldap.yml"]:::ap
+ NC["nextcloud
📍 host_vars/application/nextcloud.yml"]:::ap
+ COL["collabora
📍 host_vars/application/collabora.yml"]:::ap
DRW["drawio
📍 host_vars/application/drawio.yml"]:::ap
end
- subgraph ST["storage — backend"]
+ subgraph ST["storage — traefik backend"]
TRS["traefik (docker provider)"]:::st
- GAR["garage (S3)
📍 host_vars/storage/garage.yml
s3_domain, rpc_secret,
admin_token, s3_keys"]:::st
+ GAR["garage (S3)
📍 host_vars/storage/garage.yml"]:::st
end
Internet -->|HTTPS :443| TRDMZ
TRDMZ -->|HTTP backend| TRA
TRDMZ -->|HTTP backend| TRS
- TRA --> NC & OC & AK & COL & DRW & AKO
+ TRA --> AK & AKO & NC & COL & DRW
TRS --> GAR
NC -. S3 .-> GAR
- OC -. S3 .-> GAR
NC -. OIDC .-> AK
- OC -. OIDC .-> AK
NC -. WOPI .-> COL
- OC -. WOPI .-> COL
NC -. LDAP .-> AKO
- OC -. LDAP .-> AKO
AKO -. RPC + token .-> AK
TRDMZ -. ACME DNS-01 TSIG .-> DNS
- TRDMZ -. lookup acme-tsig .-> BAO
- AK -. lookup secrets .-> BAO
- NC -. lookup secrets .-> BAO
- GAR -. lookup secrets .-> BAO
+ TRDMZ -. hashi_vault acme-tsig .-> BAO
+ AK -. hashi_vault secrets .-> BAO
+ NC -. hashi_vault secrets .-> BAO
+ GAR -. hashi_vault secrets .-> BAO
```
-## 4. Deploy-Flow
+> **Hinweis:** `opencloud`, `send` und `openforms` sind in
+> [playbooks/site.yml](playbooks/site.yml) als Plays vorhanden, haben
+> aber in `demo-gymburgdorf` aktuell keine entsprechende Gruppe in
+> [hosts.yml](inventories/demo-gymburgdorf/hosts.yml) — die Plays
+> laufen also durch, ohne Targets zu finden. Wenn diese Services für
+> einen Mandanten gewünscht sind, jeweils `_servers`-Gruppe in
+> `hosts.yml` und `host_vars/application/.yml` ergänzen.
+>
+> Der Host `turn` ist in `all_servers` und damit auch in
+> `traefik_servers`, aber es gibt **keine** Service-Gruppe für ihn —
+> aktuell läuft auf `turn` nur die `base`- und `traefik`-Rolle.
+
+## 6. Deploy-Flow
+
+Reihenfolge aus [playbooks/site.yml](playbooks/site.yml):
```mermaid
sequenceDiagram
- participant U as User (make)
- participant M as Makefile
+ participant U as User
participant A as ansible-playbook
participant V as OpenBao
participant H as Hosts
- U->>M: make bao
- M->>V: bao login (OIDC)
- V-->>M: VAULT_TOKEN
- U->>M: make deploy_site_demo_gymburgdorf
- M->>A: ansible-playbook site.yml -i inventories/demo-gymburgdorf/hosts.yml
- A->>A: lade group_vars/all → group_vars/traefik_servers → group_vars/backend_servers → host_vars/*
- A->>V: community.hashi_vault Lookups (acme-tsig, secrets)
+ U->>U: bao login + export VAULT_TOKEN
+ U->>A: make deploy_site_demo_gymburgdorf
+ A->>A: lade vars: role defaults → group_vars/all → group_vars/<groups> → host_vars/<host>
+ A->>V: community.hashi_vault Lookups
(acme-tsig, service-secrets)
V-->>A: secret values
- A->>H: Play "base" → all_servers
- A->>H: Play "traefik" → traefik_servers (dmz auf reverseproxy, backend auf application/storage)
- A->>H: Play "garage" → storage
- A->>H: Play "authentik / nextcloud / collabora / ..." → application
+ A->>H: Play 1 — base (alle Hosts)
+ A->>H: Play 2 — traefik (alle Hosts: dmz auf reverseproxy, backend sonst)
+ A->>H: Play 3 — httpbin
+ A->>H: Play 4 — 389ds
+ A->>H: Play 5 — keycloak
+ A->>H: Play 6 — garage (storage)
+ A->>H: Play 7 — collabora (application)
+ A->>H: Play 8 — authentik (application)
+ A->>H: Play 9 — authentik_outpost_ldap (application)
+ A->>H: Play 10 — nextcloud (application)
+ A->>H: Play 11 — drawio (application)
+ A->>H: Play 12 — send
+ A->>H: Play 13 — openforms
+ A->>H: Play 14 — opencloud
```
-## 5. Variablen-Cheatsheet — wo gehört was hin?
+Plays ohne passende Gruppen-Mitglieder (`httpbin_servers`, `ds389_servers`,
+`keycloak_servers`, `send_servers`, `openforms_servers`,
+`opencloud_servers` in dieser Inventory) laufen no-op durch.
+
+`--diff` ist im Target gesetzt → Änderungen pro Task sichtbar.
+
+## 7. Traefik-Modi (DMZ vs Backend)
+
+**`traefik_mode: dmz`** — public-facing Reverse Proxy auf `reverseproxy`:
+
+- **file provider** mit `services.yml` für statisches Routing.
+- Kein Docker-Socket gemountet, keine lokalen Container.
+- Routet zu `backend_host`-Adressen anderer Maschinen.
+- Backends werden über `traefik_dmz_exposed_services` (Liste in
+ `host_vars/reverseproxy/`) deklariert. Selektive Backend-Auswahl
+ zusätzlich über `traefik_backend_servers_to_proxy`.
+
+**`traefik_mode: backend`** — application/storage:
+
+- Mountet `/var/run/docker.sock`.
+- **docker provider**: Auto-Discovery via Container-Labels (`traefik.enable=true`).
+- Services werden lokal exponiert; die DMZ-Traefik routet von aussen
+ dorthin (Klartext-HTTP, siehe [§ 8](#8-security-und-demo-only-defaults)).
+
+**Beide Modi** unterstützen ACME via RFC2136 DNS Challenge oder
+Self-Signed (`traefik_cert_mode: acme | selfsigned`).
+
+## 8. Security und Demo-Only-Defaults
+
+> Dieses Repo ist explizit für **Demo-Setups** gedacht. Alle Default-
+> Werte in den Rollen sind unsicher und werden in `demo-*`-Inventories
+> über Bao-Lookups oder host_vars überschrieben. Für Prod-Deployments
+> gilt zusätzlich der Härtungs-Block weiter unten.
+
+### Secret-Pattern (Bao-Lookup)
+
+```yaml
+# group_vars/.../.yml oder host_vars/.../.yml
+authentik_secret_key: "{{ lookup('community.hashi_vault.hashi_vault',
+ vault_mount + '/data/authentik:secret_key',
+ url=vault_addr) }}"
+```
+
+- `vault_mount` und `vault_addr` aus
+ [group_vars/all/vault.yml](inventories/demo-gymburgdorf/group_vars/all/vault.yml).
+- KV-v2-Pfade brauchen explizit `/data/` im Pfad — Ansible löst das
+ nicht selber auf.
+- `vault_mount` ist pro Inventory eindeutig (`demo-gymburgdorf`,
+ `demo-phbern`, …) → Mandant-Isolation in Bao via Mount + Policy.
+
+### Demo-Only-Defaults — Override-Pflicht
+
+Diese Defaults in `digitalboard.core` sind unsicher. In jeder
+**Prod-tauglichen** Deployment müssen sie via Bao-Lookup oder
+host_var überschrieben werden:
+
+| Variable | Default | Wo überschreiben |
+|---|---|---|
+| `keycloak_admin_password` | `changeme` | host_vars `keycloak_servers` |
+| `keycloak_postgres_password` | `changeme` | dito |
+| `authentik_secret_key` | `changeme-generate-a-random-string` | `host_vars/application/authentik.yml` |
+| `authentik_postgres_password` | `changeme` | dito |
+| `nextcloud_admin_password` | `admin` | `host_vars/application/nextcloud.yml` |
+| `nextcloud_postgres_password` | `changeme` | dito |
+| `nextcloud_s3_key` / `nextcloud_s3_secret` | `changeme` / `changeme` | dito |
+| `garage_webui_password` | `admin` | `host_vars/storage/garage.yml` |
+| `garage_rpc_secret` | `0123…cdef` (64-hex Konstante) | dito |
+| `garage_admin_token` | identisch zu `rpc_secret` | dito |
+| `garage_metrics_token` | identisch zu `rpc_secret` | dito |
+
+> **Konvention:** Jeder Wert, der oben in der Tabelle steht, **muss**
+> in `demo-*/host_vars/.../...yml` einen Bao-Lookup haben, bevor das
+> Inventory als deploy-fähig gilt.
+
+### Threat-Boundaries (Stand: Demo)
+
+| Boundary | Status | Notiz |
+|---|---|---|
+| DMZ ↔ Backend (172.16.9 ↔ 172.16.19) | **Klartext-HTTP** | Auth-Bearer, OIDC-Code, Session-Cookies reisen unverschlüsselt. Für Demo ok, für Prod: mTLS oder WireGuard-Overlay. |
+| Host-Firewall | **fehlt** | Die `base`-Rolle installiert kein UFW/nftables. Segmentation hängt am Hypervisor/VLAN. |
+| SSH | `ansible_user: root` | Kein Bastion, kein Jumphost. Key-Distribution out-of-band. |
+| Authentik-SPOF | **akzeptiert** | IDP und SP-Services auf demselben Host (`application`). Authentik-Ausfall = Login-Ausfall inkl. LDAP-Outpost. Kein Break-Glass-Pfad. |
+| ACME-TSIG-Key | Bao-Lookup | Eine TSIG-Key pro Demo-Zone (`acme_update_key_demo_gymb`), Zonen-isoliert. Rotation manuell. |
+| Backup/DR | **out-of-scope** | Garage `replication_factor: 1` (default), kein Postgres-Backup-Job, kein Bao-Snapshot-Cron. |
+
+### Für Prod-Adaption ergänzen
+
+- Host-FW (`base`-Rolle erweitern oder eigene `firewall`-Rolle).
+- mTLS oder WireGuard zwischen DMZ und Backend.
+- Authentik auf separaten Host, mit Recovery-Admin-Token.
+- Bao-Policies pro Inventory-Mount (read-only für Deploy-Token,
+ write-only für Bootstrap-Job).
+- Backup-Cron für Postgres + Garage + Bao.
+- SSH-Bastion + Key-Rotation.
+
+## 9. Variablen-Cheatsheet
| Variable | Wohin in `demo-gymburgdorf/` | Warum |
|---|---|---|
-| `vault_addr`, `vault_mount` | `group_vars/all/vault.yml` ✅ | Vault-Endpoint gilt site-weit |
-| `docker_registry_mirrors` | `group_vars/all/docker.yml` ✅ | Pulls aus Mirror auf allen Hosts |
-| `traefik_acme_*`, `traefik_use_ssl`, `traefik_cert_mode` | `group_vars/traefik_servers/traefik.yml` ✅ | Gilt für alle Traefik-Instanzen (dmz + backend) |
-| `traefik_mode: backend` | `group_vars/backend_servers/traefik.yml` ✅ | Default für app + storage |
-| `traefik_mode: dmz` | `host_vars/reverseproxy/traefik.yml` ✅ | Host-spezifischer Override |
-| `traefik_dmz_exposed_services` | **`host_vars/reverseproxy/`** | Liste, welche Backend-Services die DMZ proxyt (nur dort sinnvoll) |
-| `nextcloud_*`, `authentik_*`, `opencloud_*`, `collabora_*`, `drawio_*` | **`host_vars/application/.yml`** | Service läuft genau auf `application` |
-| `garage_*` | **`host_vars/storage/garage.yml`** | Service läuft genau auf `storage` |
-| Secrets (Passwords, Tokens, Keys) | Inline-Variable mit `lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/', url=vault_addr)` | Single source of truth; Pattern wie bei `_acme_tsig` |
+| `vault_addr`, `vault_mount` | `group_vars/all/vault.yml` | Bao-Endpoint gilt site-weit |
+| `docker_registry_mirrors` | `group_vars/all/docker.yml` | Pulls aus Mirror auf allen Hosts |
+| `traefik_acme_*`, `traefik_use_ssl`, `traefik_cert_mode` | `group_vars/traefik_servers/traefik.yml` | Gilt für alle Traefik-Instanzen (dmz + backend) |
+| `traefik_mode: backend` | `group_vars/backend_servers/traefik.yml` | Default für app + storage |
+| `traefik_mode: dmz` | `host_vars/reverseproxy/traefik.yml` | Host-spezifischer Override |
+| `traefik_dmz_exposed_services` | `host_vars/reverseproxy/` | Liste der DMZ-Backends — nur dort sinnvoll |
+| `nextcloud_*`, `authentik_*`, `collabora_*`, `drawio_*` | `host_vars/application/.yml` | Service läuft auf `application` |
+| `garage_*` | `host_vars/storage/garage.yml` | Service läuft auf `storage` |
+| Secrets (Passwords, Tokens, Keys) | inline-Variable mit `lookup('community.hashi_vault.hashi_vault', …)` | Single source of truth via Bao |
-## 6. Traefik-Modi (zentral für die Architektur)
+## 10. Walkthrough: Neuen Demo-Mandanten anlegen
-**`traefik_mode: dmz`** (Public-facing Reverse Proxy auf `reverseproxy`):
+Empfohlene Vorlage: **`demo-gymburgdorf`** (nicht `vagrant`, weil die
+Gruppen-Topologie inkompatibel ist).
-- Aggregiert Services von Backend-Servern via
- `traefik_dmz_exposed_services` (Host-Variablen).
-- Nutzt **file provider** mit `services.yml` für statisches Routing.
-- Kein Docker-Socket gemountet — keine lokalen Container.
-- Routet zu `backend_host` auf anderen Maschinen.
-- Selektive Backend-Auswahl via `traefik_backend_servers_to_proxy`.
+1. **Inventory kopieren:**
-**`traefik_mode: backend`** (Application/Storage Server):
+ ```bash
+ cp -r inventories/demo-gymburgdorf inventories/demo-
+ ```
-- Mountet Docker-Socket (`/var/run/docker.sock`).
-- Nutzt **docker provider** für Auto-Discovery lokaler Container.
-- Services mit Label `traefik.enable=true` werden automatisch exponiert.
-- Beide Modi unterstützen ACME (RFC2136 DNS Challenge) oder Self-Signed.
+2. **`hosts.yml` anpassen:** IPs, Hostnames pro Host.
-## 7. Was im aktuellen `demo-gymburgdorf` noch fehlt
+3. **`group_vars/all/vault.yml`** — `vault_mount` auf den neuen
+ Mandant-Mount setzen (`demo-`).
-Im Vergleich zur `vagrant`-Inventory (Referenz) fehlen für eine
-vollständige Deployment:
+4. **`group_vars/traefik_servers/traefik.yml`** — `traefik_acme_dns_zone`
+ und `traefik_acme_tsig_*`-Lookup-Pfade auf die neue Zone /
+ den neuen Bao-Pfad biegen.
-- `host_vars/application/main.yml` — Backbone-Vars für den Host
- (FQDN-Pattern, gemeinsame Defaults).
-- `host_vars/application/{nextcloud,opencloud,authentik,authentik_outpost_ldap,collabora,drawio}.yml`
- — die service-spezifischen Konfigurationen.
-- `host_vars/storage/{main.yml,garage.yml}` — Garage-Cluster-Setup.
-- `host_vars/reverseproxy/<…>.yml` mit `traefik_dmz_exposed_services`
- — sonst routet die DMZ nichts.
+5. **`host_vars/application/*.yml`** und **`host_vars/storage/*.yml`**
+ durchgehen: FQDNs auf das neue Domain-Pattern (z. B.
+ `*..souveredu.ch`), Bao-Lookup-Pfade auf `demo-/data/…`.
-Die `vagrant`-Inventory ist das Template: dieselbe Struktur auf
-`application`/`storage` mappen, FQDNs auf `*.gymb.souveredu.ch`
-umstellen, Secrets durch Bao-Lookups ersetzen.
+6. **OpenBao vorbereiten** (out-of-band, nicht via Ansible):
+ - Neuen KV-v2-Mount `demo-` anlegen.
+ - Secrets schreiben: `acme-tsig`, `authentik`, `nextcloud`,
+ `garage`, … (siehe [§ 8](#8-security-und-demo-only-defaults)
+ für die Override-Pflicht-Liste).
+ - Policy für den Deploy-Token: read auf `demo-/data/*`.
+
+7. **DNS:** TSIG-Update-Zone (`demo-._acme.digitalboard.ch`) bei
+ `ns1.digitalboard.ch` anlegen, CNAMEs `_acme-challenge.*..`
+ dorthin.
+
+8. **Makefile** — neues Target nach Vorbild von
+ `deploy_site_demo_gymburgdorf` ergänzen und in `deploy_site_demo`
+ einreihen.
+
+9. **Smoke-Test:** `ansible all -i inventories/demo-/hosts.yml -m ping`.
+
+10. **Deploy:** Bao-Login + `make deploy_site_demo_`.
+
+## 11. Bekannte Lücken und Trade-offs
+
+- **`opencloud`-Inventory in `demo-gymburgdorf`:** keine
+ `opencloud_servers`-Gruppe vorhanden. Wenn benötigt, ergänzen wie in
+ [§ 5](#5-service-layout-und-variablen-verortung) beschrieben.
+- **`turn`-Host:** in DMZ definiert, aber keine STUN/TURN-Rolle in
+ [playbooks/site.yml](playbooks/site.yml). Wird aktuell nur per
+ `base`+`traefik` provisioniert.
+- **Idempotenz:** Rollen sind Docker-Compose-basiert; Re-Runs führen
+ ggf. Container-Restarts aus, wenn sich Compose-Inputs ändern. Kein
+ spezieller Rollback-Mechanismus — bei Fehlschlag manuell auf den
+ vorigen Stand zurücksetzen.
+- **TLS-Renewal:** Erfolgt durch Traefik intern via ACME. Kein
+ externer Renew-Cron im Repo.
+- **CI/Testing:** Aktuell nicht im Repo. Smoke-Test via `make ping_demo`.
+- **Logs:** Traefik läuft in `demo-gymburgdorf` und `vagrant` mit
+ `traefik_log_level: DEBUG` (Role-Default ist `INFO`) — vor
+ Prod-Adaption auf `INFO` oder `WARN` reduzieren.
diff --git a/Makefile b/Makefile
index 67b2a75..ffa5afe 100644
--- a/Makefile
+++ b/Makefile
@@ -20,7 +20,7 @@ ping_demo:
deploy_site_demo_gymburgdorf:
echo "deploying demo site gymburgdorf"
- ansible-playbook playbooks/site.yml -i inventories/demo-gymburgdorf/hosts.yml --diff
+ ansible-playbook playbooks/site.yml -i inventories/demo-gymburgdorf/hosts.yml --diff
deploy_site_demo_mbazürich:
echo "deploying demo site mbazürich"
diff --git a/README.md b/README.md
index 4bfdb6d..fead38b 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,61 @@
# reference-ansible
+Ansible-Setup für Demo-Deployments (`demo-gymburgdorf`,
+`demo-mbazürich`, `demo-phbern`) und lokale Vagrant-Tests. Rollen
+kommen aus der Collection
+[`digitalboard.core`](https://git.digitalboard.ch/Digitalboard/digitalboard.core)
+(via [requirements.yml](requirements.yml)).
+> Architektur, Variablen-Hierarchie, Service-Topologie und der
+> Walkthrough zum Aufsetzen neuer Mandanten: siehe
+> **[ARCHITECTURE.md](ARCHITECTURE.md)**.
-### Secrets
-Secrets are managed using [OpenBao](https://bao.digitalboard.ch).
-The bao CLI needs to be installed. e.g `sudo pacman -S openbao python-hvac`
+## Voraussetzungen
+
+- `ansible` (Core ≥ 2.15)
+- `bao` CLI ([OpenBao](https://openbao.org/)) — z. B.
+ `sudo pacman -S openbao python-hvac` (Arch) oder Homebrew
+- `python-hvac` (für `community.hashi_vault` Lookups)
+
+## Setup
+
+```bash
+make install # installiert digitalboard.core + community.hashi_vault nach ./collections/
+```
+
+## Secrets (OpenBao)
+
+Vor jedem Deploy in **derselben Shell** authentisieren:
-Authenticate and export token before running playbooks:
```bash
export BAO_ADDR=https://bao.digitalboard.ch
bao login -method=oidc -path=Digitalboard
export VAULT_TOKEN=$(bao print token)
-```
\ No newline at end of file
+```
+
+> ⚠️ `make bao` allein reicht **nicht** — jedes `make`-Target läuft in
+> einer neuen Shell, der dort gesetzte `VAULT_TOKEN` lebt nur während
+> `make bao` selbst. Entweder die drei Befehle oben manuell im Shell
+> ausführen oder `make bao deploy_site_demo_gymburgdorf` als **einen**
+> Aufruf chainen.
+
+## Deploy
+
+```bash
+make ping_demo # Smoke-Test gegen alle Demo-Inventories
+make deploy_site_demo_gymburgdorf # einzelnes Demo-Site
+make deploy_site_demo # alle drei Demo-Sites
+```
+
+Auf macOS setzt das [Makefile](Makefile) zusätzlich
+`OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` — ohne diese Env-Var
+crashen Ansible-Forks beim ersten `community.hashi_vault`-Lookup.
+
+## Inventories
+
+| Inventory | Zweck |
+|---|---|
+| [`inventories/demo-gymburgdorf/`](inventories/demo-gymburgdorf/) | Demo-Mandant — als Vorlage für neue Mandanten empfohlen, siehe [ARCHITECTURE.md § 10](ARCHITECTURE.md#10-walkthrough-neuen-demo-mandanten-anlegen) |
+| [`inventories/demo-mbazürich/`](inventories/demo-mbazürich/) | Demo-Mandant |
+| [`inventories/demo-phbern/`](inventories/demo-phbern/) | Demo-Mandant |
+| [`inventories/vagrant/`](inventories/vagrant/) | lokale Test-VMs; **inkompatible Gruppen-Topologie** zu den Demo-Inventories |
diff --git a/inventories/demo-gymburgdorf/group_vars/traefik_servers/traefik.yml b/inventories/demo-gymburgdorf/group_vars/traefik_servers/traefik.yml
index c42dc79..de50728 100644
--- a/inventories/demo-gymburgdorf/group_vars/traefik_servers/traefik.yml
+++ b/inventories/demo-gymburgdorf/group_vars/traefik_servers/traefik.yml
@@ -2,6 +2,7 @@ _acme_tsig: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data
traefik_use_ssl: true
traefik_cert_mode: "acme"
+traefik_ssl_email: "hostmaster@digitalboard.ch"
traefik_log_level: DEBUG
traefik_network: proxy
@@ -11,3 +12,9 @@ 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-gymburgdorf/host_vars/application/authentik.yml b/inventories/demo-gymburgdorf/host_vars/application/authentik.yml
index aba8775..fd94e37 100644
--- a/inventories/demo-gymburgdorf/host_vars/application/authentik.yml
+++ b/inventories/demo-gymburgdorf/host_vars/application/authentik.yml
@@ -5,7 +5,13 @@
# nextcloud_oidc_secret
_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}"
-authentik_domain: "auth.gymb.souveredu.ch"
+# 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.
+authentik_domains:
+ - "auth.gymb.souveredu.ch"
+ - "auth.int.gymb.souveredu.ch"
authentik_secret_key: "{{ _authentik.secret_key }}"
authentik_postgres_password: "{{ _authentik.postgres_password }}"
@@ -20,7 +26,9 @@ authentik_ldap_outpost:
name: "ldap-outpost"
token: "{{ _authentik.ldap_outpost_token }}"
config:
- authentik_host: "https://auth.gymb.souveredu.ch/"
+ # Outpost pulls config from authentik over the internal FQDN — keeps
+ # the round-trip in the LAN with a valid cert.
+ authentik_host: "https://auth.int.gymb.souveredu.ch/"
log_level: "info"
# OIDC clients
diff --git a/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml b/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml
index c02b660..562f979 100644
--- a/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml
+++ b/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml
@@ -3,5 +3,5 @@
# authenticate against the authentik server it talks to.
_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}"
-authentik_outpost_ldap_host: "https://auth.gymb.souveredu.ch"
+authentik_outpost_ldap_host: "https://auth.int.gymb.souveredu.ch"
authentik_outpost_ldap_token: "{{ _authentik.ldap_outpost_token }}"
diff --git a/inventories/demo-gymburgdorf/host_vars/application/collabora.yml b/inventories/demo-gymburgdorf/host_vars/application/collabora.yml
index b02760c..da251df 100644
--- a/inventories/demo-gymburgdorf/host_vars/application/collabora.yml
+++ b/inventories/demo-gymburgdorf/host_vars/application/collabora.yml
@@ -1,8 +1,16 @@
---
-collabora_domain: "office.gymb.souveredu.ch"
+# 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.gymb.souveredu.ch"
+ - "office.int.gymb.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.gymb.souveredu.ch"
+ - "cloud.int.gymb.souveredu.ch"
collabora_frame_ancestors:
- "cloud.gymb.souveredu.ch"
diff --git a/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml b/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml
index a1719f4..06bb35a 100644
--- a/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml
+++ b/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml
@@ -4,16 +4,31 @@
_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) }}"
-nextcloud_domain: "cloud.gymb.souveredu.ch"
+# 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.gymb.souveredu.ch"
+ - "cloud.int.gymb.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.gymb.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.gymb.souveredu.ch"
+nextcloud_collabora_domain: "office.int.gymb.souveredu.ch"
+nextcloud_collabora_public_domain: "office.gymb.souveredu.ch"
# Draw.io integration
nextcloud_enable_drawio: true
@@ -30,12 +45,14 @@ nextcloud_apps_to_install:
- files_lock
- notify_push
-# S3 primary storage via Garage
+# 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: "{{ hostvars['storage']['garage_s3_domain'] }}"
+nextcloud_s3_host: "s3.int.gymb.souveredu.ch"
nextcloud_s3_port: 443
nextcloud_s3_ssl: true
nextcloud_s3_usepath_style: true
@@ -81,6 +98,11 @@ 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"
scope: "openid email profile"
unique_uid: true
diff --git a/inventories/demo-gymburgdorf/host_vars/application/traefik.yml b/inventories/demo-gymburgdorf/host_vars/application/traefik.yml
new file mode 100644
index 0000000..d18cd49
--- /dev/null
+++ b/inventories/demo-gymburgdorf/host_vars/application/traefik.yml
@@ -0,0 +1,29 @@
+---
+# 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.gymb.souveredu.ch
+ backend_host: auth.int.gymb.souveredu.ch
+ port: 443
+ protocol: https
+ - name: nextcloud
+ domain: cloud.gymb.souveredu.ch
+ backend_host: cloud.int.gymb.souveredu.ch
+ port: 443
+ protocol: https
+ - name: collabora
+ domain: office.gymb.souveredu.ch
+ backend_host: office.int.gymb.souveredu.ch
+ port: 443
+ 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.
+ port: 443
+ protocol: https
diff --git a/inventories/demo-gymburgdorf/host_vars/reverseproxy/traefik.yml b/inventories/demo-gymburgdorf/host_vars/reverseproxy/traefik.yml
index c43e6be..48f6d08 100644
--- a/inventories/demo-gymburgdorf/host_vars/reverseproxy/traefik.yml
+++ b/inventories/demo-gymburgdorf/host_vars/reverseproxy/traefik.yml
@@ -1,28 +1,22 @@
---
traefik_mode: dmz
-traefik_dmz_exposed_services:
- - name: authentik
- domain: auth.gymb.souveredu.ch
- port: 443
- protocol: https
- - name: nextcloud
- domain: cloud.gymb.souveredu.ch
- port: 443
- protocol: https
- - name: collabora
- domain: office.gymb.souveredu.ch
- port: 443
- protocol: https
- - name: drawio
- domain: draw.gymb.souveredu.ch
- port: 443
- protocol: https
- - name: garage-webui
- domain: console.s3.gymb.souveredu.ch
- port: 443
- protocol: https
- - name: garage-s3
- domain: s3.gymb.souveredu.ch
- port: 443
- protocol: https
+# 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-gymburgdorf/host_vars/storage/garage.yml b/inventories/demo-gymburgdorf/host_vars/storage/garage.yml
index bc392c9..a6e0d79 100644
--- a/inventories/demo-gymburgdorf/host_vars/storage/garage.yml
+++ b/inventories/demo-gymburgdorf/host_vars/storage/garage.yml
@@ -3,7 +3,12 @@
# rpc_secret, admin_token, metrics_token, webui_password
_garage: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/garage', url=vault_addr) }}"
-garage_s3_domain: "s3.gymb.souveredu.ch"
+# 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.gymb.souveredu.ch"
+ - "s3.int.gymb.souveredu.ch"
garage_webui_domain: "console.s3.gymb.souveredu.ch"
garage_use_ssl: true
garage_webui_enabled: true
diff --git a/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml b/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml
new file mode 100644
index 0000000..6cd16f5
--- /dev/null
+++ b/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml
@@ -0,0 +1,16 @@
+---
+# Services hosted on `storage` that the DMZ reverseproxy should forward
+# public traffic to. See application/traefik.yml for the mechanism.
+traefik_dmz_exposed_services:
+ - name: garage-s3
+ domain: s3.gymb.souveredu.ch
+ backend_host: s3.int.gymb.souveredu.ch
+ port: 443
+ protocol: https
+ - name: garage-webui
+ domain: console.s3.gymb.souveredu.ch
+ # No internal FQDN/cert SAN for console.s3 yet — would need an
+ # extra_domain on garage-webui. Until then this route will 500
+ # against the storage backend (cert mismatch on raw IP).
+ port: 443
+ protocol: https
diff --git a/playbooks/site.yml b/playbooks/site.yml
index ba9f7ca..13b0a7c 100644
--- a/playbooks/site.yml
+++ b/playbooks/site.yml
@@ -71,17 +71,17 @@
roles:
- digitalboard.core.drawio
-- name: Deploy send service
- hosts: send_servers
- become: yes
- roles:
- - digitalboard.core.send
+# - name: Deploy send service
+# hosts: send_servers
+# become: yes
+# roles:
+# - digitalboard.core.send
-- name: Deploy openforms service
- hosts: openforms_servers
- become: yes
- roles:
- - digitalboard.core.openforms
+# - name: Deploy openforms service
+# hosts: openforms_servers
+# become: yes
+# roles:
+# - digitalboard.core.openforms
- name: Deploy opencloud service
hosts: opencloud_servers