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.
This commit is contained in:
parent
82f0db8fe3
commit
c67e9aac43
13 changed files with 552 additions and 170 deletions
486
ARCHITECTURE.md
486
ARCHITECTURE.md
|
|
@ -1,49 +1,151 @@
|
||||||
<!-- markdownlint-disable MD013 MD060 -->
|
<!-- markdownlint-disable MD013 MD060 MD051 -->
|
||||||
# Architekturskizze — `demo-gymburgdorf`
|
# Architektur — `reference-ansible`
|
||||||
|
|
||||||
Diese Skizze zeigt, wie das `reference-ansible`-Repo am Beispiel der
|
Dieses Dokument beschreibt die Architektur des Repos `reference-ansible`
|
||||||
Inventory `demo-gymburgdorf` funktioniert: welche Hosts existieren,
|
und nutzt die Inventory `inventories/demo-gymburgdorf/` als
|
||||||
welche Rollen darauf laufen, wo welche Variablen hingehören und wie
|
durchgehendes Beispiel. Es dient sowohl als Onboarding-Doku für neue
|
||||||
Secrets aus OpenBao gelookupt werden.
|
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
|
**Letzte Aktualisierung:** 2026-05-18 · **Owner:** @sbaerlocher
|
||||||
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
|
|
||||||
|
|
||||||
R["<b>role defaults/main.yml</b><br/>(niedrigste Precedence)<br/>~180 Variablen über 12 Rollen<br/>z.B. traefik_use_ssl: false<br/>keycloak_admin_password: changeme"]:::rolelayer
|
## Inhalt
|
||||||
|
|
||||||
GA["<b>group_vars/all/</b><br/>docker.yml → docker_registry_mirrors<br/>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["<b>group_vars/traefik_servers/</b><br/>traefik.yml<br/>traefik_use_ssl, traefik_cert_mode: acme<br/>traefik_acme_dns_zone<br/>traefik_acme_tsig_* (Vault-Lookup!)"]:::grouplayer
|
## 0. Glossar
|
||||||
|
|
||||||
GB["<b>group_vars/backend_servers/</b><br/>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.<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. |
|
||||||
|
|
||||||
HR["<b>host_vars/reverseproxy/</b><br/>traefik.yml → traefik_mode: dmz"]:::hostlayer
|
## 1. Repo-Layout und Roles-Herkunft
|
||||||
|
|
||||||
HA["<b>host_vars/application/</b> (fehlt aktuell!)<br/>FQDNs, OIDC-Clients, DB-Passwords<br/>nextcloud_domain, authentik_domain, ..."]:::hostlayer
|
```text
|
||||||
|
reference-ansible/
|
||||||
HS["<b>host_vars/storage/</b> (fehlt aktuell!)<br/>garage_s3_domain, garage_*_token<br/>traefik_dmz_exposed_services (für DMZ)"]:::hostlayer
|
├── Makefile # Deploy-Targets, OIDC-Login, OBJC-Fork-Workaround
|
||||||
|
├── ansible.cfg # collections_path, remote_user=root, hashi_vault auth_method=token
|
||||||
V["<b>HashiCorp Vault / OpenBao</b><br/>bao.digitalboard.ch<br/>mount: demo-gymburgdorf<br/>z.B. demo-gymburgdorf/data/acme-tsig"]:::vaultlayer
|
├── requirements.yml # community.hashi_vault + digitalboard.core (Git)
|
||||||
|
├── playbooks/site.yml # Play-Sequenz (14 Plays, siehe § 6)
|
||||||
R --> GA --> GT --> GB --> HR
|
├── collections/ # ← installiert von `make install`, gitignored
|
||||||
GB --> HA
|
│ └── ansible_collections/
|
||||||
GB --> HS
|
│ └── digitalboard/core/
|
||||||
GT -.Lookup zur Laufzeit.-> V
|
│ └── roles/ # 🔑 HIER liegen die Rollen, NICHT im Repo-Root
|
||||||
HA -.Lookup zur Laufzeit.-> V
|
└── inventories/
|
||||||
HS -.Lookup zur Laufzeit.-> V
|
├── 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`
|
> **Wichtig:** Es gibt **kein** `roles/`-Verzeichnis im Repo-Root. Alle
|
||||||
schlägt also `group_vars/backend_servers/traefik_mode: backend` —
|
> Rollen kommen aus der Collection `digitalboard.core` (siehe
|
||||||
möglich, weil `reverseproxy` *nicht* in `backend_servers` ist.
|
> [requirements.yml](requirements.yml)), installiert via `make install`
|
||||||
|
> nach `./collections/`. Plays referenzieren sie per FQCN
|
||||||
|
> `digitalboard.core.<role>`.
|
||||||
|
|
||||||
## 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 <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:
|
||||||
|
|
||||||
|
```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["<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_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](#8-security-und-demo-only-defaults).
|
||||||
|
|
||||||
|
## 4. Inventory-Topologie (`demo-gymburgdorf`)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
|
|
@ -52,33 +154,40 @@ flowchart LR
|
||||||
classDef stor fill:#dbeafe,stroke:#1e40af,color:#000
|
classDef stor fill:#dbeafe,stroke:#1e40af,color:#000
|
||||||
classDef turn fill:#fef9c3,stroke:#854d0e,color:#000
|
classDef turn fill:#fef9c3,stroke:#854d0e,color:#000
|
||||||
|
|
||||||
subgraph ALL["group: all_servers (alle Hosts)"]
|
subgraph ALL["group: all_servers"]
|
||||||
direction LR
|
direction LR
|
||||||
subgraph DMZ["DMZ-Segment 172.16.9.0/24"]
|
subgraph DMZ["DMZ 172.16.9.0/24"]
|
||||||
RP["<b>reverseproxy</b><br/>172.16.9.111<br/>traefik_mode: dmz"]:::dmz
|
RP["<b>reverseproxy</b><br/>172.16.9.111<br/>traefik_mode: dmz"]:::dmz
|
||||||
TURN["<b>turn</b><br/>172.16.9.112<br/>(STUN/TURN)"]:::turn
|
TURN["<b>turn</b><br/>172.16.9.112<br/>(noch keine Rolle in site.yml)"]:::turn
|
||||||
end
|
end
|
||||||
subgraph BE["Backend-Segment 172.16.19.0/24<br/>(group: backend_servers)"]
|
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/>+ nextcloud, opencloud,<br/>collabora, drawio,<br/>authentik, authentik_outpost_ldap"]:::app
|
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
|
ST["<b>storage</b><br/>172.16.19.102<br/>traefik_mode: backend<br/>+ garage (S3)"]:::stor
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
RP -.HTTP/HTTPS reverse proxy.-> APP
|
RP -.HTTPS in, HTTP out.-> APP
|
||||||
RP -.HTTP/HTTPS reverse proxy.-> ST
|
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
|
| Gruppe | Mitglieder | Zweck |
|
||||||
(DMZ-Modus für `reverseproxy`, Backend-Modus für `application`/`storage`).
|
|---|---|---|
|
||||||
- `backend_servers = {application, storage}` → setzt
|
| `all_servers` | `reverseproxy`, `application`, `storage`, `turn` | Basis-Rolle für alle Hosts |
|
||||||
`traefik_mode: backend` via group_vars.
|
| `traefik_servers` | `children: all_servers` (= alle 4 Hosts) | Traefik überall; DMZ/Backend per `traefik_mode` |
|
||||||
- Service-Gruppen (`nextcloud_servers`, `garage_servers`, …) sind
|
| `backend_servers` | `application`, `storage` | setzt `traefik_mode: backend` per group_var |
|
||||||
Single-Host-Wrapper, mit denen `playbooks/site.yml` gezielt
|
| `garage_servers` | `storage` | Single-Host-Wrapper für Garage-Role |
|
||||||
deploybare Rollen targetet.
|
| `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
|
```mermaid
|
||||||
flowchart TB
|
flowchart TB
|
||||||
|
|
@ -88,120 +197,257 @@ flowchart TB
|
||||||
classDef ext fill:#e9d5ff,stroke:#6b21a8,color:#000
|
classDef ext fill:#e9d5ff,stroke:#6b21a8,color:#000
|
||||||
|
|
||||||
Internet((Internet))
|
Internet((Internet))
|
||||||
DNS["DNS ns1.digitalboard.ch<br/>RFC2136 TSIG (key: acme_update_key_demo_gymb)<br/>dynamic zone: demo-gymb._acme.digitalboard.ch<br/>CNAME-bridge: _acme-challenge.*.gymb.souveredu.ch"]:::ext
|
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
|
BAO["OpenBao<br/>bao.digitalboard.ch<br/>mount: demo-gymburgdorf"]:::ext
|
||||||
|
|
||||||
subgraph RP["<b>reverseproxy</b> — traefik dmz"]
|
subgraph RP["<b>reverseproxy</b> — traefik dmz"]
|
||||||
TRDMZ["traefik (file provider)<br/>📍 group_vars/traefik_servers/traefik.yml<br/> → acme, tsig, ssl<br/>📍 host_vars/reverseproxy/traefik.yml<br/> → traefik_mode: dmz<br/>📍 host_vars/reverseproxy/...<br/> → traefik_dmz_exposed_services"]:::rp
|
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
|
end
|
||||||
|
|
||||||
subgraph APP["<b>application</b> — backend"]
|
subgraph APP["<b>application</b> — traefik backend"]
|
||||||
TRA["traefik (docker provider)<br/>📍 group_vars/backend_servers<br/> → traefik_mode: backend"]:::ap
|
TRA["traefik (docker provider)<br/>📍 group_vars/backend_servers/traefik.yml"]:::ap
|
||||||
NC["nextcloud<br/>📍 host_vars/application/nextcloud.yml<br/> domain, postgres_pw, oidc, s3, ldap"]:::ap
|
AK["authentik (OIDC + LDAP-Outpost-Backend)<br/>📍 host_vars/application/authentik.yml"]:::ap
|
||||||
OC["opencloud<br/>📍 host_vars/application/opencloud.yml<br/> oidc_issuer, ldap, s3"]:::ap
|
AKO["authentik_outpost_ldap<br/>📍 host_vars/application/authentik_outpost_ldap.yml"]:::ap
|
||||||
AK["authentik<br/>📍 host_vars/application/authentik.yml<br/> secret_key, postgres, ldap_apps, oidc_apps"]:::ap
|
NC["nextcloud<br/>📍 host_vars/application/nextcloud.yml"]:::ap
|
||||||
AKO["authentik_outpost_ldap<br/>📍 host_vars/application/authentik_outpost_ldap.yml<br/> host, token"]:::ap
|
COL["collabora<br/>📍 host_vars/application/collabora.yml"]:::ap
|
||||||
COL["collabora<br/>📍 host_vars/application/collabora.yml<br/> domain, allowed_domains"]:::ap
|
|
||||||
DRW["drawio<br/>📍 host_vars/application/drawio.yml"]:::ap
|
DRW["drawio<br/>📍 host_vars/application/drawio.yml"]:::ap
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph ST["<b>storage</b> — backend"]
|
subgraph ST["<b>storage</b> — traefik backend"]
|
||||||
TRS["traefik (docker provider)"]:::st
|
TRS["traefik (docker provider)"]:::st
|
||||||
GAR["garage (S3)<br/>📍 host_vars/storage/garage.yml<br/> s3_domain, rpc_secret,<br/> admin_token, s3_keys"]:::st
|
GAR["garage (S3)<br/>📍 host_vars/storage/garage.yml"]:::st
|
||||||
end
|
end
|
||||||
|
|
||||||
Internet -->|HTTPS :443| TRDMZ
|
Internet -->|HTTPS :443| TRDMZ
|
||||||
TRDMZ -->|HTTP backend| TRA
|
TRDMZ -->|HTTP backend| TRA
|
||||||
TRDMZ -->|HTTP backend| TRS
|
TRDMZ -->|HTTP backend| TRS
|
||||||
TRA --> NC & OC & AK & COL & DRW & AKO
|
TRA --> AK & AKO & NC & COL & DRW
|
||||||
TRS --> GAR
|
TRS --> GAR
|
||||||
|
|
||||||
NC -. S3 .-> GAR
|
NC -. S3 .-> GAR
|
||||||
OC -. S3 .-> GAR
|
|
||||||
NC -. OIDC .-> AK
|
NC -. OIDC .-> AK
|
||||||
OC -. OIDC .-> AK
|
|
||||||
NC -. WOPI .-> COL
|
NC -. WOPI .-> COL
|
||||||
OC -. WOPI .-> COL
|
|
||||||
NC -. LDAP .-> AKO
|
NC -. LDAP .-> AKO
|
||||||
OC -. LDAP .-> AKO
|
|
||||||
AKO -. RPC + token .-> AK
|
AKO -. RPC + token .-> AK
|
||||||
|
|
||||||
TRDMZ -. ACME DNS-01 TSIG .-> DNS
|
TRDMZ -. ACME DNS-01 TSIG .-> DNS
|
||||||
TRDMZ -. lookup acme-tsig .-> BAO
|
TRDMZ -. hashi_vault acme-tsig .-> BAO
|
||||||
AK -. lookup secrets .-> BAO
|
AK -. hashi_vault secrets .-> BAO
|
||||||
NC -. lookup secrets .-> BAO
|
NC -. hashi_vault secrets .-> BAO
|
||||||
GAR -. lookup 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 `<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](playbooks/site.yml):
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant U as User (make)
|
participant U as User
|
||||||
participant M as Makefile
|
|
||||||
participant A as ansible-playbook
|
participant A as ansible-playbook
|
||||||
participant V as OpenBao
|
participant V as OpenBao
|
||||||
participant H as Hosts
|
participant H as Hosts
|
||||||
|
|
||||||
U->>M: make bao
|
U->>U: bao login + export VAULT_TOKEN
|
||||||
M->>V: bao login (OIDC)
|
U->>A: make deploy_site_demo_gymburgdorf
|
||||||
V-->>M: VAULT_TOKEN
|
A->>A: lade vars: role defaults → group_vars/all → group_vars/<groups> → host_vars/<host>
|
||||||
U->>M: make deploy_site_demo_gymburgdorf
|
A->>V: community.hashi_vault Lookups<br/>(acme-tsig, service-secrets)
|
||||||
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)
|
|
||||||
V-->>A: secret values
|
V-->>A: secret values
|
||||||
A->>H: Play "base" → all_servers
|
A->>H: Play 1 — base (alle Hosts)
|
||||||
A->>H: Play "traefik" → traefik_servers (dmz auf reverseproxy, backend auf application/storage)
|
A->>H: Play 2 — traefik (alle Hosts: dmz auf reverseproxy, backend sonst)
|
||||||
A->>H: Play "garage" → storage
|
A->>H: Play 3 — httpbin
|
||||||
A->>H: Play "authentik / nextcloud / collabora / ..." → application
|
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/.../<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](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 |
|
| Variable | Wohin in `demo-gymburgdorf/` | Warum |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `vault_addr`, `vault_mount` | `group_vars/all/vault.yml` ✅ | Vault-Endpoint gilt site-weit |
|
| `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 |
|
| `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_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: backend` | `group_vars/backend_servers/traefik.yml` | Default für app + storage |
|
||||||
| `traefik_mode: dmz` | `host_vars/reverseproxy/traefik.yml` ✅ | Host-spezifischer Override |
|
| `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) |
|
| `traefik_dmz_exposed_services` | `host_vars/reverseproxy/` | Liste der DMZ-Backends — nur dort sinnvoll |
|
||||||
| `nextcloud_*`, `authentik_*`, `opencloud_*`, `collabora_*`, `drawio_*` | **`host_vars/application/<service>.yml`** | Service läuft genau auf `application` |
|
| `nextcloud_*`, `authentik_*`, `collabora_*`, `drawio_*` | `host_vars/application/<service>.yml` | Service läuft auf `application` |
|
||||||
| `garage_*` | **`host_vars/storage/garage.yml`** | Service läuft genau auf `storage` |
|
| `garage_*` | `host_vars/storage/garage.yml` | Service läuft auf `storage` |
|
||||||
| Secrets (Passwords, Tokens, Keys) | Inline-Variable mit `lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/<path>', url=vault_addr)` | Single source of truth; Pattern wie bei `_acme_tsig` |
|
| 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
|
1. **Inventory kopieren:**
|
||||||
`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`.
|
|
||||||
|
|
||||||
**`traefik_mode: backend`** (Application/Storage Server):
|
```bash
|
||||||
|
cp -r inventories/demo-gymburgdorf inventories/demo-<kunde>
|
||||||
|
```
|
||||||
|
|
||||||
- Mountet Docker-Socket (`/var/run/docker.sock`).
|
2. **`hosts.yml` anpassen:** IPs, Hostnames pro Host.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 7. Was im aktuellen `demo-gymburgdorf` noch fehlt
|
3. **`group_vars/all/vault.yml`** — `vault_mount` auf den neuen
|
||||||
|
Mandant-Mount setzen (`demo-<kunde>`).
|
||||||
|
|
||||||
Im Vergleich zur `vagrant`-Inventory (Referenz) fehlen für eine
|
4. **`group_vars/traefik_servers/traefik.yml`** — `traefik_acme_dns_zone`
|
||||||
vollständige Deployment:
|
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
|
5. **`host_vars/application/*.yml`** und **`host_vars/storage/*.yml`**
|
||||||
(FQDN-Pattern, gemeinsame Defaults).
|
durchgehen: FQDNs auf das neue Domain-Pattern (z. B.
|
||||||
- `host_vars/application/{nextcloud,opencloud,authentik,authentik_outpost_ldap,collabora,drawio}.yml`
|
`*.<kunde>.souveredu.ch`), Bao-Lookup-Pfade auf `demo-<kunde>/data/…`.
|
||||||
— 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.
|
|
||||||
|
|
||||||
Die `vagrant`-Inventory ist das Template: dieselbe Struktur auf
|
6. **OpenBao vorbereiten** (out-of-band, nicht via Ansible):
|
||||||
`application`/`storage` mappen, FQDNs auf `*.gymb.souveredu.ch`
|
- Neuen KV-v2-Mount `demo-<kunde>` anlegen.
|
||||||
umstellen, Secrets durch Bao-Lookups ersetzen.
|
- 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-<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](#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.
|
||||||
|
|
|
||||||
2
Makefile
2
Makefile
|
|
@ -20,7 +20,7 @@ ping_demo:
|
||||||
|
|
||||||
deploy_site_demo_gymburgdorf:
|
deploy_site_demo_gymburgdorf:
|
||||||
echo "deploying demo site 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:
|
deploy_site_demo_mbazürich:
|
||||||
echo "deploying demo site mbazürich"
|
echo "deploying demo site mbazürich"
|
||||||
|
|
|
||||||
57
README.md
57
README.md
|
|
@ -1,14 +1,61 @@
|
||||||
# reference-ansible
|
# 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
|
## Voraussetzungen
|
||||||
Secrets are managed using [OpenBao](https://bao.digitalboard.ch).
|
|
||||||
The bao CLI needs to be installed. e.g `sudo pacman -S openbao python-hvac`
|
- `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
|
```bash
|
||||||
export BAO_ADDR=https://bao.digitalboard.ch
|
export BAO_ADDR=https://bao.digitalboard.ch
|
||||||
bao login -method=oidc -path=Digitalboard
|
bao login -method=oidc -path=Digitalboard
|
||||||
export VAULT_TOKEN=$(bao print token)
|
export VAULT_TOKEN=$(bao print token)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> ⚠️ `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 |
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ _acme_tsig: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data
|
||||||
|
|
||||||
traefik_use_ssl: true
|
traefik_use_ssl: true
|
||||||
traefik_cert_mode: "acme"
|
traefik_cert_mode: "acme"
|
||||||
|
traefik_ssl_email: "hostmaster@digitalboard.ch"
|
||||||
traefik_log_level: DEBUG
|
traefik_log_level: DEBUG
|
||||||
traefik_network: proxy
|
traefik_network: proxy
|
||||||
|
|
||||||
|
|
@ -11,3 +12,9 @@ traefik_acme_tsig_algorithm: "hmac-sha256"
|
||||||
traefik_acme_tsig_key: "{{ _acme_tsig.tsig_key }}"
|
traefik_acme_tsig_key: "{{ _acme_tsig.tsig_key }}"
|
||||||
traefik_acme_tsig_secret: "{{ _acme_tsig.tsig_secret }}"
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,13 @@
|
||||||
# nextcloud_oidc_secret
|
# nextcloud_oidc_secret
|
||||||
_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}"
|
_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_secret_key: "{{ _authentik.secret_key }}"
|
||||||
authentik_postgres_password: "{{ _authentik.postgres_password }}"
|
authentik_postgres_password: "{{ _authentik.postgres_password }}"
|
||||||
|
|
||||||
|
|
@ -20,7 +26,9 @@ authentik_ldap_outpost:
|
||||||
name: "ldap-outpost"
|
name: "ldap-outpost"
|
||||||
token: "{{ _authentik.ldap_outpost_token }}"
|
token: "{{ _authentik.ldap_outpost_token }}"
|
||||||
config:
|
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"
|
log_level: "info"
|
||||||
|
|
||||||
# OIDC clients
|
# OIDC clients
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,5 @@
|
||||||
# authenticate against the authentik server it talks to.
|
# authenticate against the authentik server it talks to.
|
||||||
_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}"
|
_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 }}"
|
authentik_outpost_ldap_token: "{{ _authentik.ldap_outpost_token }}"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
collabora_allowed_domains:
|
||||||
- "cloud.gymb.souveredu.ch"
|
- "cloud.gymb.souveredu.ch"
|
||||||
|
- "cloud.int.gymb.souveredu.ch"
|
||||||
|
|
||||||
collabora_frame_ancestors:
|
collabora_frame_ancestors:
|
||||||
- "cloud.gymb.souveredu.ch"
|
- "cloud.gymb.souveredu.ch"
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,31 @@
|
||||||
_nextcloud: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/nextcloud', url=vault_addr) }}"
|
_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) }}"
|
_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_postgres_password: "{{ _nextcloud.postgres_password }}"
|
||||||
nextcloud_admin_user: admin
|
nextcloud_admin_user: admin
|
||||||
nextcloud_admin_password: "{{ _nextcloud.admin_password }}"
|
nextcloud_admin_password: "{{ _nextcloud.admin_password }}"
|
||||||
|
|
||||||
nextcloud_enable_notify_push: true
|
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
|
# 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_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
|
# Draw.io integration
|
||||||
nextcloud_enable_drawio: true
|
nextcloud_enable_drawio: true
|
||||||
|
|
@ -30,12 +45,14 @@ nextcloud_apps_to_install:
|
||||||
- files_lock
|
- files_lock
|
||||||
- notify_push
|
- 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_use_s3_storage: true
|
||||||
nextcloud_s3_key: "{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='storage')['key_id'] }}"
|
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_secret: "{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='storage')['secret_key'] }}"
|
||||||
nextcloud_s3_bucket: "nextcloud"
|
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_port: 443
|
||||||
nextcloud_s3_ssl: true
|
nextcloud_s3_ssl: true
|
||||||
nextcloud_s3_usepath_style: true
|
nextcloud_s3_usepath_style: true
|
||||||
|
|
@ -81,6 +98,11 @@ nextcloud_oidc_providers:
|
||||||
display_name: "Login with Authentik"
|
display_name: "Login with Authentik"
|
||||||
client_id: nextcloud
|
client_id: nextcloud
|
||||||
client_secret: "{{ _authentik.nextcloud_oidc_secret }}"
|
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"
|
discovery_url: "https://auth.gymb.souveredu.ch/application/o/nextcloud/.well-known/openid-configuration"
|
||||||
scope: "openid email profile"
|
scope: "openid email profile"
|
||||||
unique_uid: true
|
unique_uid: true
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,28 +1,22 @@
|
||||||
---
|
---
|
||||||
traefik_mode: dmz
|
traefik_mode: dmz
|
||||||
|
|
||||||
traefik_dmz_exposed_services:
|
# The DMZ traefik discovers which services to expose by reading
|
||||||
- name: authentik
|
# traefik_dmz_exposed_services from each backend host's host_vars
|
||||||
domain: auth.gymb.souveredu.ch
|
# (application/traefik.yml, storage/traefik.yml). See the role's
|
||||||
port: 443
|
# tasks/main.yml — set_fact "Build service registry from backend
|
||||||
protocol: https
|
# servers (DMZ mode)".
|
||||||
- name: nextcloud
|
|
||||||
domain: cloud.gymb.souveredu.ch
|
# From the DMZ network the public ns1 IP (193.43.183.169) is not
|
||||||
port: 443
|
# reachable on port 53, but the internal address (172.16.9.169) is.
|
||||||
protocol: https
|
# Override the group-level traefik_acme_dns_nameserver from bao so
|
||||||
- name: collabora
|
# lego's RFC2136 updates land at the internal interface. The TSIG
|
||||||
domain: office.gymb.souveredu.ch
|
# key/secret are the same; only the transport target changes.
|
||||||
port: 443
|
traefik_acme_dns_nameserver: "172.16.9.169"
|
||||||
protocol: https
|
|
||||||
- name: drawio
|
# Lego's propagation check normally polls the NS hostnames listed in
|
||||||
domain: draw.gymb.souveredu.ch
|
# the zone's SOA (ns1.digitalboard.ch.) — which resolves to the
|
||||||
port: 443
|
# public IP that's unreachable from this DMZ host. Skip that check;
|
||||||
protocol: https
|
# lego still polls via the resolver above before asking LE to
|
||||||
- name: garage-webui
|
# validate.
|
||||||
domain: console.s3.gymb.souveredu.ch
|
traefik_acme_disable_ans_checks: true
|
||||||
port: 443
|
|
||||||
protocol: https
|
|
||||||
- name: garage-s3
|
|
||||||
domain: s3.gymb.souveredu.ch
|
|
||||||
port: 443
|
|
||||||
protocol: https
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@
|
||||||
# rpc_secret, admin_token, metrics_token, webui_password
|
# rpc_secret, admin_token, metrics_token, webui_password
|
||||||
_garage: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/garage', url=vault_addr) }}"
|
_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_webui_domain: "console.s3.gymb.souveredu.ch"
|
||||||
garage_use_ssl: true
|
garage_use_ssl: true
|
||||||
garage_webui_enabled: true
|
garage_webui_enabled: true
|
||||||
|
|
|
||||||
16
inventories/demo-gymburgdorf/host_vars/storage/traefik.yml
Normal file
16
inventories/demo-gymburgdorf/host_vars/storage/traefik.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -71,17 +71,17 @@
|
||||||
roles:
|
roles:
|
||||||
- digitalboard.core.drawio
|
- digitalboard.core.drawio
|
||||||
|
|
||||||
- name: Deploy send service
|
# - name: Deploy send service
|
||||||
hosts: send_servers
|
# hosts: send_servers
|
||||||
become: yes
|
# become: yes
|
||||||
roles:
|
# roles:
|
||||||
- digitalboard.core.send
|
# - digitalboard.core.send
|
||||||
|
|
||||||
- name: Deploy openforms service
|
# - name: Deploy openforms service
|
||||||
hosts: openforms_servers
|
# hosts: openforms_servers
|
||||||
become: yes
|
# become: yes
|
||||||
roles:
|
# roles:
|
||||||
- digitalboard.core.openforms
|
# - digitalboard.core.openforms
|
||||||
|
|
||||||
- name: Deploy opencloud service
|
- name: Deploy opencloud service
|
||||||
hosts: opencloud_servers
|
hosts: opencloud_servers
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue