# Architektur — `reference-ansible` 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. > **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). **Letzte Aktualisierung:** 2026-05-18 · **Owner:** @sbaerlocher ## Inhalt - [§ 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) ## 0. Glossar | 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. | ## 1. Repo-Layout und Roles-Herkunft ```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 ``` > **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. 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 classDef dmz fill:#fee2e2,stroke:#991b1b,color:#000 classDef app fill:#dcfce7,stroke:#166534,color:#000 classDef stor fill:#dbeafe,stroke:#1e40af,color:#000 classDef turn fill:#fef9c3,stroke:#854d0e,color:#000 subgraph ALL["group: all_servers"] direction LR subgraph DMZ["DMZ 172.16.9.0/24"] RP["reverseproxy
172.16.9.111
traefik_mode: dmz"]:::dmz TURN["turn
172.16.9.112
(noch keine Rolle in site.yml)"]:::turn end 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 -.HTTPS in, HTTP out.-> APP RP -.HTTPS in, HTTP out.-> ST ``` **Gruppen-Mitgliedschaften (aus [hosts.yml](inventories/demo-gymburgdorf/hosts.yml)):** | 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 | > **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 classDef rp fill:#fee2e2,stroke:#991b1b,color:#000 classDef ap fill:#dcfce7,stroke:#166534,color:#000 classDef st fill:#dbeafe,stroke:#1e40af,color:#000 classDef ext fill:#e9d5ff,stroke:#6b21a8,color:#000 Internet((Internet)) 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
📍 host_vars/reverseproxy/traefik.yml
→ traefik_mode: dmz
→ traefik_dmz_exposed_services"]:::rp end 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 — traefik backend"] TRS["traefik (docker provider)"]:::st GAR["garage (S3)
📍 host_vars/storage/garage.yml"]:::st end Internet -->|HTTPS :443| TRDMZ TRDMZ -->|HTTP backend| TRA TRDMZ -->|HTTP backend| TRS TRA --> AK & AKO & NC & COL & DRW TRS --> GAR NC -. S3 .-> GAR NC -. OIDC .-> AK NC -. WOPI .-> COL NC -. LDAP .-> AKO AKO -. RPC + token .-> AK TRDMZ -. ACME DNS-01 TSIG .-> DNS TRDMZ -. hashi_vault acme-tsig .-> BAO AK -. hashi_vault secrets .-> BAO NC -. hashi_vault secrets .-> BAO GAR -. hashi_vault secrets .-> BAO ``` > **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 participant A as ansible-playbook participant V as OpenBao participant H as Hosts 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 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 ``` 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` | 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 | ## 10. Walkthrough: Neuen Demo-Mandanten anlegen Empfohlene Vorlage: **`demo-gymburgdorf`** (nicht `vagrant`, weil die Gruppen-Topologie inkompatibel ist). 1. **Inventory kopieren:** ```bash cp -r inventories/demo-gymburgdorf inventories/demo- ``` 2. **`hosts.yml` anpassen:** IPs, Hostnames pro Host. 3. **`group_vars/all/vault.yml`** — `vault_mount` auf den neuen Mandant-Mount setzen (`demo-`). 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. 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/…`. 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.