- 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.
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
- § 1 — Repo-Layout und Roles-Herkunft
- § 2 — Setup und Voraussetzungen
- § 3 — Variablen-Hierarchie
- § 4 — Inventory-Topologie (
demo-gymburgdorf) - § 5 — Service-Layout und Variablen-Verortung
- § 6 — Deploy-Flow
- § 7 — Traefik-Modi (DMZ vs Backend)
- § 8 — Security und Demo-Only-Defaults
- § 9 — Variablen-Cheatsheet
- § 10 — Walkthrough: Neuen Demo-Mandanten anlegen
- § 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.<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 Collectiondigitalboard.core(siehe requirements.yml), installiert viamake installnach./collections/. Plays referenzieren sie per FQCNdigitalboard.core.<role>.
2. Setup und Voraussetzungen
Tools auf dem Control-Node:
ansible(Core ≥ 2.15)baoCLI (OpenBao) — z. B.sudo pacman -S openbao python-hvac(Arch) oder Homebrewpython-hvac(fürcommunity.hashi_vaultLookups)- 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 baoallein reicht nicht: jedesmake-Target startet eine neue Shell, der dort gesetzteVAULT_TOKENlebt nur währendmake baoselbst. Entweder die drei Befehle oben manuell ausführen odermake bao deploy_site_demo_gymburgdorfals 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/<r>/defaults/main.yml"]:::role
GA["<b>group_vars/all/</b><br/>vault.yml, docker.yml"]:::group
GG["<b>group_vars/<group>/</b><br/>traefik_servers/, backend_servers/<br/>(parallele Gruppen, gemerged via<br/>ansible_group_priority)"]:::group
HV["<b>host_vars/<host>/</b><br/>(höchste der drei Inventory-Quellen)"]:::host
BAO["<b>OpenBao</b><br/>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/<group>/sind parallel, nicht hierarchisch geschachtelt.traefik_serversundbackend_serverswerden nachansible_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 ausgroup_vars/backend_servers/— und zwar nur, weilreverseproxynicht Mitglied vonbackend_serversist (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:vagrantstrukturiert Traefik anders — über die Children-Gruppentraefik_servers_dmzundtraefik_servers_backendstatt überbackend_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,sendundopenformssind in playbooks/site.yml als Plays vorhanden, haben aber indemo-gymburgdorfaktuell 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 inhosts.ymlundhost_vars/application/<service>.ymlergänzen.Der Host
turnist inall_serversund damit auch intraefik_servers, aber es gibt keine Service-Gruppe für ihn — aktuell läuft aufturnnur diebase- undtraefik-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/<groups> → host_vars/<host>
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.ymlfü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 inhost_vars/reverseproxy/) deklariert. Selektive Backend-Auswahl zusätzlich übertraefik_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_mountundvault_addraus group_vars/all/vault.yml.- KV-v2-Pfade brauchen explizit
/data/im Pfad — Ansible löst das nicht selber auf. vault_mountist 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/.../...ymleinen 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 eigenefirewall-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).
-
Inventory kopieren:
cp -r inventories/demo-gymburgdorf inventories/demo-<kunde> -
hosts.ymlanpassen: IPs, Hostnames pro Host. -
group_vars/all/vault.yml—vault_mountauf den neuen Mandant-Mount setzen (demo-<kunde>). -
group_vars/traefik_servers/traefik.yml—traefik_acme_dns_zoneundtraefik_acme_tsig_*-Lookup-Pfade auf die neue Zone / den neuen Bao-Pfad biegen. -
host_vars/application/*.ymlundhost_vars/storage/*.ymldurchgehen: FQDNs auf das neue Domain-Pattern (z. B.*.<kunde>.souveredu.ch), Bao-Lookup-Pfade aufdemo-<kunde>/data/…. -
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/*.
- Neuen KV-v2-Mount
-
DNS: TSIG-Update-Zone (
demo-<kunde>._acme.digitalboard.ch) beins1.digitalboard.chanlegen, CNAMEs_acme-challenge.*.<kunde>.<tld>dorthin. -
Makefile — neues Target nach Vorbild von
deploy_site_demo_gymburgdorfergänzen und indeploy_site_demoeinreihen. -
Smoke-Test:
ansible all -i inventories/demo-<kunde>/hosts.yml -m ping. -
Deploy: Bao-Login +
make deploy_site_demo_<kunde>.
11. Bekannte Lücken und Trade-offs
opencloud-Inventory indemo-gymburgdorf: keineopencloud_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 perbase+traefikprovisioniert.- 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-gymburgdorfundvagrantmittraefik_log_level: DEBUG(Role-Default istINFO) — vor Prod-Adaption aufINFOoderWARNreduzieren.