reference-ansible/ARCHITECTURE.md
Simon Bärlocher c67e9aac43
chore(demo-gymburgdorf): finish ACME, LDAP, DMZ routing for live inventory
- ACME via DNS-01 against internal NS (172.16.9.169) with TCP-only +
  disableANSChecks so the DMZ traefik can issue LE certs without
  reaching public NS IPs.
- Migrate single-domain vars to `*_domains` lists (authentik, nextcloud,
  collabora, garage_s3) so public + *.int.* SANs share one cert and
  server-to-server traffic stays in the LAN.
- Wire `traefik_dmz_exposed_services` per backend host (application,
  storage) with explicit `backend_host` overrides pointing at internal
  FQDNs — DMZ traefik now validates upstream certs against SAN names.
- Nextcloud notify_push setup on internal FQDN to avoid DMZ hairpin;
  collabora WOPI / authentik LDAP outpost wired to *.int.* equivalents.
2026-05-27 23:18:58 +02:00

21 KiB

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.

Letzte Aktualisierung: 2026-05-18 · Owner: @sbaerlocher

Inhalt

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.<fqdn> 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

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), installiert via make install nach ./collections/. Plays referenzieren sie per FQCN digitalboard.core.<role>.

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 gesetzt; ohne crashen Ansible-Forks beim Bao-Lookup)

Initial-Setup:

git clone <repo>
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:

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:

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):

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["<b>role defaults</b><br/>(niedrigste Precedence)<br/>collections/.../roles/&lt;r&gt;/defaults/main.yml"]:::role
    GA["<b>group_vars/all/</b><br/>vault.yml, docker.yml"]:::group
    GG["<b>group_vars/&lt;group&gt;/</b><br/>traefik_servers/, backend_servers/<br/>(parallele Gruppen, gemerged via<br/>ansible_group_priority)"]:::group
    HV["<b>host_vars/&lt;host&gt;/</b><br/>(höchste der drei Inventory-Quellen)"]:::host
    BAO["<b>OpenBao</b><br/>Lookup zur Laufzeit"]:::vault

    R --> |"&lt;wird überschrieben von&gt;"| GA
    GA --> |"&lt;wird überschrieben von&gt;"| GG
    GG --> |"&lt;wird überschrieben von&gt;"| HV
    HV -.community.hashi_vault.-> BAO
    GG -.community.hashi_vault.-> BAO

Wichtige Eigenschaften:

  • Mehrere group_vars/<group>/ 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/<host>/ 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.

4. Inventory-Topologie (demo-gymburgdorf)

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["<b>reverseproxy</b><br/>172.16.9.111<br/>traefik_mode: dmz"]:::dmz
            TURN["<b>turn</b><br/>172.16.9.112<br/>(noch keine Rolle in site.yml)"]:::turn
        end
        subgraph BE["Backend 172.16.19.0/24<br/>group: backend_servers"]
            APP["<b>application</b><br/>172.16.19.101<br/>traefik_mode: backend<br/>+ authentik, authentik_outpost_ldap,<br/>  nextcloud, collabora, drawio"]:::app
            ST["<b>storage</b><br/>172.16.19.102<br/>traefik_mode: backend<br/>+ garage (S3)"]:::stor
        end
    end

    RP -.HTTPS in, HTTP out.-> APP
    RP -.HTTPS in, HTTP out.-> ST

Gruppen-Mitgliedschaften (aus 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 für die empfohlene Vorlage.

5. Service-Layout und Variablen-Verortung

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<br/>RFC2136 TSIG<br/>Zone: demo-gymb._acme.digitalboard.ch<br/>CNAME-Bridge: _acme-challenge.*.gymb.souveredu.ch"]:::ext
    BAO["OpenBao<br/>bao.digitalboard.ch<br/>mount: demo-gymburgdorf"]:::ext

    subgraph RP["<b>reverseproxy</b> — traefik dmz"]
        TRDMZ["traefik (file provider)<br/>📍 group_vars/traefik_servers/traefik.yml<br/>📍 host_vars/reverseproxy/traefik.yml<br/>  → traefik_mode: dmz<br/>  → traefik_dmz_exposed_services"]:::rp
    end

    subgraph APP["<b>application</b> — traefik backend"]
        TRA["traefik (docker provider)<br/>📍 group_vars/backend_servers/traefik.yml"]:::ap
        AK["authentik (OIDC + LDAP-Outpost-Backend)<br/>📍 host_vars/application/authentik.yml"]:::ap
        AKO["authentik_outpost_ldap<br/>📍 host_vars/application/authentik_outpost_ldap.yml"]:::ap
        NC["nextcloud<br/>📍 host_vars/application/nextcloud.yml"]:::ap
        COL["collabora<br/>📍 host_vars/application/collabora.yml"]:::ap
        DRW["drawio<br/>📍 host_vars/application/drawio.yml"]:::ap
    end

    subgraph ST["<b>storage</b> — traefik backend"]
        TRS["traefik (docker provider)"]:::st
        GAR["garage (S3)<br/>📍 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 als Plays vorhanden, haben aber in demo-gymburgdorf aktuell keine entsprechende Gruppe in hosts.yml — die Plays laufen also durch, ohne Targets zu finden. Wenn diese Services für einen Mandanten gewünscht sind, jeweils <service>_servers-Gruppe in hosts.yml und host_vars/application/<service>.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:

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/&lt;groups&gt; → host_vars/&lt;host&gt;
    A->>V: community.hashi_vault Lookups<br/>(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).

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)

# group_vars/.../<service>.yml oder host_vars/.../<service>.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.
  • 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/<service>.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:

    cp -r inventories/demo-gymburgdorf inventories/demo-<kunde>
    
  2. hosts.yml anpassen: IPs, Hostnames pro Host.

  3. group_vars/all/vault.ymlvault_mount auf den neuen Mandant-Mount setzen (demo-<kunde>).

  4. group_vars/traefik_servers/traefik.ymltraefik_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. *.<kunde>.souveredu.ch), Bao-Lookup-Pfade auf demo-<kunde>/data/….

  6. OpenBao vorbereiten (out-of-band, nicht via Ansible):

    • Neuen KV-v2-Mount demo-<kunde> anlegen.
    • Secrets schreiben: acme-tsig, authentik, nextcloud, garage, … (siehe § 8 für die Override-Pflicht-Liste).
    • Policy für den Deploy-Token: read auf demo-<kunde>/data/*.
  7. DNS: TSIG-Update-Zone (demo-<kunde>._acme.digitalboard.ch) bei ns1.digitalboard.ch anlegen, CNAMEs _acme-challenge.*.<kunde>.<tld> 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-<kunde>/hosts.yml -m ping.

  10. Deploy: Bao-Login + make deploy_site_demo_<kunde>.

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 beschrieben.
  • turn-Host: in DMZ definiert, aber keine STUN/TURN-Rolle in 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.