diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 3cbcaf3..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(rtk curl *)", - "Bash(python3 -c ' *)", - "Bash(ssh root@172.16.19.101 'docker exec nextcloud-nextcloud-1 php /var/www/html/occ config:app:get core \"appstoreFetcher-lastFailure\" 2>&1; echo ===; docker exec nextcloud-nextcloud-1 php /var/www/html/occ config:app:get core \"appstore-fetcher-lastFailure\" 2>&1; echo ===; docker exec nextcloud-nextcloud-1 php /var/www/html/occ config:app:get core \"AppFetcher-lastFailure\" 2>&1')" - ] - } -} diff --git a/.gitignore b/.gitignore index fd9891c..75839df 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,6 @@ .snapshots/* # Idea /.idea/ -# macOS -.DS_Store # Ansible /collections/ansible_collections/ .vagrant/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..807f408 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,207 @@ + +# Architekturskizze — `demo-gymburgdorf` + +Diese Skizze zeigt, wie das `reference-ansible`-Repo am Beispiel der +Inventory `demo-gymburgdorf` funktioniert: welche Hosts existieren, +welche Rollen darauf laufen, wo welche Variablen hingehören und wie +Secrets aus OpenBao gelookupt werden. + +## 1. Variablen-Hierarchie (Ansible Precedence) + +```mermaid +flowchart TB + classDef rolelayer fill:#fef3c7,stroke:#92400e,color:#000 + classDef grouplayer fill:#dbeafe,stroke:#1e40af,color:#000 + classDef hostlayer fill:#dcfce7,stroke:#166534,color:#000 + classDef vaultlayer fill:#fee2e2,stroke:#991b1b,color:#000 + + R["role defaults/main.yml
(niedrigste Precedence)
~180 Variablen über 12 Rollen
z.B. traefik_use_ssl: false
keycloak_admin_password: changeme"]:::rolelayer + + GA["group_vars/all/
docker.yml → docker_registry_mirrors
vault.yml → vault_addr, vault_mount"]:::grouplayer + + GT["group_vars/traefik_servers/
traefik.yml
traefik_use_ssl, traefik_cert_mode: acme
traefik_acme_dns_zone
traefik_acme_tsig_* (Vault-Lookup!)"]:::grouplayer + + GB["group_vars/backend_servers/
traefik.yml → traefik_mode: backend"]:::grouplayer + + HR["host_vars/reverseproxy/
traefik.yml → traefik_mode: dmz"]:::hostlayer + + HA["host_vars/application/ (fehlt aktuell!)
FQDNs, OIDC-Clients, DB-Passwords
nextcloud_domain, authentik_domain, ..."]:::hostlayer + + HS["host_vars/storage/ (fehlt aktuell!)
garage_s3_domain, garage_*_token
traefik_dmz_exposed_services (für DMZ)"]:::hostlayer + + V["HashiCorp Vault / OpenBao
bao.digitalboard.ch
mount: demo-gymburgdorf
z.B. demo-gymburgdorf/data/acme-tsig"]:::vaultlayer + + R --> GA --> GT --> GB --> HR + GB --> HA + GB --> HS + GT -.Lookup zur Laufzeit.-> V + HA -.Lookup zur Laufzeit.-> V + HS -.Lookup zur Laufzeit.-> V +``` + +Höhere Ebene überschreibt tiefere. `host_vars/reverseproxy/traefik_mode: dmz` +schlägt also `group_vars/backend_servers/traefik_mode: backend` — +möglich, weil `reverseproxy` *nicht* in `backend_servers` ist. + +## 2. Inventory-Topologie demo-gymburgdorf + +```mermaid +flowchart LR + classDef dmz fill:#fee2e2,stroke:#991b1b,color:#000 + classDef app fill:#dcfce7,stroke:#166534,color:#000 + classDef stor fill:#dbeafe,stroke:#1e40af,color:#000 + classDef turn fill:#fef9c3,stroke:#854d0e,color:#000 + + subgraph ALL["group: all_servers (alle Hosts)"] + direction LR + subgraph DMZ["DMZ-Segment 172.16.9.0/24"] + RP["reverseproxy
172.16.9.111
traefik_mode: dmz"]:::dmz + TURN["turn
172.16.9.112
(STUN/TURN)"]:::turn + end + subgraph BE["Backend-Segment 172.16.19.0/24
(group: backend_servers)"] + APP["application
172.16.19.101
traefik_mode: backend
+ nextcloud, opencloud,
collabora, drawio,
authentik, authentik_outpost_ldap"]:::app + ST["storage
172.16.19.102
traefik_mode: backend
+ garage (S3)"]:::stor + end + end + + RP -.HTTP/HTTPS reverse proxy.-> APP + RP -.HTTP/HTTPS reverse proxy.-> ST +``` + +Gruppen-Mitgliedschaften (`hosts.yml`): + +- `traefik_servers` ⊇ `all_servers` → **alle 4 Hosts** bekommen Traefik + (DMZ-Modus für `reverseproxy`, Backend-Modus für `application`/`storage`). +- `backend_servers = {application, storage}` → setzt + `traefik_mode: backend` via group_vars. +- Service-Gruppen (`nextcloud_servers`, `garage_servers`, …) sind + Single-Host-Wrapper, mit denen `playbooks/site.yml` gezielt + deploybare Rollen targetet. + +## 3. Service-Layout & Variablen-Verortung + +```mermaid +flowchart TB + classDef rp fill:#fee2e2,stroke:#991b1b,color:#000 + classDef ap fill:#dcfce7,stroke:#166534,color:#000 + classDef st fill:#dbeafe,stroke:#1e40af,color:#000 + classDef ext fill:#e9d5ff,stroke:#6b21a8,color:#000 + + Internet((Internet)) + DNS["DNS ns1.digitalboard.ch
RFC2136 TSIG (key: acme_update_key_demo_gymb)
dynamic zone: demo-gymb._acme.digitalboard.ch
CNAME-bridge: _acme-challenge.*.gymb.souveredu.ch"]:::ext + BAO["OpenBao
bao.digitalboard.ch
mount: demo-gymburgdorf"]:::ext + + subgraph RP["reverseproxy — traefik dmz"] + TRDMZ["traefik (file provider)
📍 group_vars/traefik_servers/traefik.yml
→ acme, tsig, ssl
📍 host_vars/reverseproxy/traefik.yml
→ traefik_mode: dmz
📍 host_vars/reverseproxy/...
→ traefik_dmz_exposed_services"]:::rp + end + + subgraph APP["application — backend"] + TRA["traefik (docker provider)
📍 group_vars/backend_servers
→ traefik_mode: backend"]:::ap + NC["nextcloud
📍 host_vars/application/nextcloud.yml
domain, postgres_pw, oidc, s3, ldap"]:::ap + OC["opencloud
📍 host_vars/application/opencloud.yml
oidc_issuer, ldap, s3"]:::ap + AK["authentik
📍 host_vars/application/authentik.yml
secret_key, postgres, ldap_apps, oidc_apps"]:::ap + AKO["authentik_outpost_ldap
📍 host_vars/application/authentik_outpost_ldap.yml
host, token"]:::ap + COL["collabora
📍 host_vars/application/collabora.yml
domain, allowed_domains"]:::ap + DRW["drawio
📍 host_vars/application/drawio.yml"]:::ap + end + + subgraph ST["storage — backend"] + TRS["traefik (docker provider)"]:::st + GAR["garage (S3)
📍 host_vars/storage/garage.yml
s3_domain, rpc_secret,
admin_token, s3_keys"]:::st + end + + Internet -->|HTTPS :443| TRDMZ + TRDMZ -->|HTTP backend| TRA + TRDMZ -->|HTTP backend| TRS + TRA --> NC & OC & AK & COL & DRW & AKO + TRS --> GAR + + NC -. S3 .-> GAR + OC -. S3 .-> GAR + NC -. OIDC .-> AK + OC -. OIDC .-> AK + NC -. WOPI .-> COL + OC -. WOPI .-> COL + NC -. LDAP .-> AKO + OC -. LDAP .-> AKO + AKO -. RPC + token .-> AK + + TRDMZ -. ACME DNS-01 TSIG .-> DNS + TRDMZ -. lookup acme-tsig .-> BAO + AK -. lookup secrets .-> BAO + NC -. lookup secrets .-> BAO + GAR -. lookup secrets .-> BAO +``` + +## 4. Deploy-Flow + +```mermaid +sequenceDiagram + participant U as User (make) + participant M as Makefile + participant A as ansible-playbook + participant V as OpenBao + participant H as Hosts + + U->>M: make bao + M->>V: bao login (OIDC) + V-->>M: VAULT_TOKEN + U->>M: make deploy_site_demo_gymburgdorf + M->>A: ansible-playbook site.yml -i inventories/demo-gymburgdorf/hosts.yml + A->>A: lade group_vars/all → group_vars/traefik_servers → group_vars/backend_servers → host_vars/* + A->>V: community.hashi_vault Lookups (acme-tsig, secrets) + V-->>A: secret values + A->>H: Play "base" → all_servers + A->>H: Play "traefik" → traefik_servers (dmz auf reverseproxy, backend auf application/storage) + A->>H: Play "garage" → storage + A->>H: Play "authentik / nextcloud / collabora / ..." → application +``` + +## 5. Variablen-Cheatsheet — wo gehört was hin? + +| Variable | Wohin in `demo-gymburgdorf/` | Warum | +|---|---|---| +| `vault_addr`, `vault_mount` | `group_vars/all/vault.yml` ✅ | Vault-Endpoint gilt site-weit | +| `docker_registry_mirrors` | `group_vars/all/docker.yml` ✅ | Pulls aus Mirror auf allen Hosts | +| `traefik_acme_*`, `traefik_use_ssl`, `traefik_cert_mode` | `group_vars/traefik_servers/traefik.yml` ✅ | Gilt für alle Traefik-Instanzen (dmz + backend) | +| `traefik_mode: backend` | `group_vars/backend_servers/traefik.yml` ✅ | Default für app + storage | +| `traefik_mode: dmz` | `host_vars/reverseproxy/traefik.yml` ✅ | Host-spezifischer Override | +| `traefik_dmz_exposed_services` | **`host_vars/reverseproxy/`** | Liste, welche Backend-Services die DMZ proxyt (nur dort sinnvoll) | +| `nextcloud_*`, `authentik_*`, `opencloud_*`, `collabora_*`, `drawio_*` | **`host_vars/application/.yml`** | Service läuft genau auf `application` | +| `garage_*` | **`host_vars/storage/garage.yml`** | Service läuft genau auf `storage` | +| Secrets (Passwords, Tokens, Keys) | Inline-Variable mit `lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/', url=vault_addr)` | Single source of truth; Pattern wie bei `_acme_tsig` | + +## 6. Traefik-Modi (zentral für die Architektur) + +**`traefik_mode: dmz`** (Public-facing Reverse Proxy auf `reverseproxy`): + +- Aggregiert Services von Backend-Servern via + `traefik_dmz_exposed_services` (Host-Variablen). +- Nutzt **file provider** mit `services.yml` für statisches Routing. +- Kein Docker-Socket gemountet — keine lokalen Container. +- Routet zu `backend_host` auf anderen Maschinen. +- Selektive Backend-Auswahl via `traefik_backend_servers_to_proxy`. + +**`traefik_mode: backend`** (Application/Storage Server): + +- Mountet Docker-Socket (`/var/run/docker.sock`). +- Nutzt **docker provider** für Auto-Discovery lokaler Container. +- Services mit Label `traefik.enable=true` werden automatisch exponiert. +- Beide Modi unterstützen ACME (RFC2136 DNS Challenge) oder Self-Signed. + +## 7. Was im aktuellen `demo-gymburgdorf` noch fehlt + +Im Vergleich zur `vagrant`-Inventory (Referenz) fehlen für eine +vollständige Deployment: + +- `host_vars/application/main.yml` — Backbone-Vars für den Host + (FQDN-Pattern, gemeinsame Defaults). +- `host_vars/application/{nextcloud,opencloud,authentik,authentik_outpost_ldap,collabora,drawio}.yml` + — die service-spezifischen Konfigurationen. +- `host_vars/storage/{main.yml,garage.yml}` — Garage-Cluster-Setup. +- `host_vars/reverseproxy/<…>.yml` mit `traefik_dmz_exposed_services` + — sonst routet die DMZ nichts. + +Die `vagrant`-Inventory ist das Template: dieselbe Struktur auf +`application`/`storage` mappen, FQDNs auf `*.gymb.souveredu.ch` +umstellen, Secrets durch Bao-Lookups ersetzen. diff --git a/Makefile b/Makefile index 687a27f..67b2a75 100644 --- a/Makefile +++ b/Makefile @@ -10,18 +10,6 @@ bao: bao login -method=oidc -path=Digitalboard role=default $(eval export VAULT_TOKEN=$(shell bao print token)) -# Seed/merge OpenBao secrets for a demo inventory. Idempotent: existing -# keys are kept; only missing keys are generated. Pass DRY_RUN=1 to -# preview without writing. -seed_bao_gymburgdorf: - scripts/bao-seed.sh demo-gymburgdorf - -seed_bao_mbazürich: - scripts/bao-seed.sh demo-mbazürich - -seed_bao_phbern: - scripts/bao-seed.sh demo-phbern - ping_demo: echo "# pinging demo-gymburgdorf" ansible all -i inventories/demo-gymburgdorf/hosts.yml -m ping || true @@ -32,7 +20,7 @@ ping_demo: deploy_site_demo_gymburgdorf: echo "deploying demo site gymburgdorf" - ansible-playbook playbooks/site.yml -i inventories/demo-gymburgdorf/hosts.yml --diff + ansible-playbook playbooks/site.yml -i inventories/demo-gymburgdorf/hosts.yml --diff deploy_site_demo_mbazürich: echo "deploying demo site mbazürich" diff --git a/README.md b/README.md index ee1a193..4bfdb6d 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,14 @@ - # reference-ansible -## Purpose -This repository is the **Ansible setup for the Digitalboard demo -deployments** — reproducible reference tenants on which the full stack -(identity, storage, office, forms, dashboards, …) can be set up and -demonstrated end-to-end. -It contains **no** roles of its own: all logic comes from the collection -[`digitalboard.core`](https://git.digitalboard.ch/Digitalboard/digitalboard.core) -(via [requirements.yml](requirements.yml)). What lives here is the -**inventory and configuration layer** — one dedicated inventory per -tenant under `inventories/demo-*` that wires up the roles, defines hosts -and domains, and pulls secrets from [OpenBao](https://openbao.org/). The -only playbook is [playbooks/site.yml](playbooks/site.yml). - -The repository thus serves two purposes: - -- **Demo operation:** deploy and keep up to date the existing demo - tenants (`demo-gymburgdorf`, `demo-mbazürich`, `demo-phbern`). -- **Template:** create new tenants following the `demo-gymburgdorf` - pattern — see [docs/inventories.md § Walkthrough](docs/inventories.md#walkthrough-creating-a-new-demo-tenant). - -> **Demo-only.** All role defaults (passwords, tokens, RPC secrets) are -> insecure and intended exclusively for demo setups. For production -> adaptation, see [docs/secrets.md](docs/secrets.md). - -## Documentation - -In-depth documentation lives in the [`docs/`](docs/) folder — start with -the index [docs/README.md](docs/README.md): - -| Document | Content | -| --- | --- | -| [docs/getting_started.md](docs/getting_started.md) | Prerequisites (access, tools), first deploy step by step | -| [docs/operations.md](docs/operations.md) | Setup, prerequisites, deploy flow, make targets, smoke test, known gaps | -| [docs/secrets.md](docs/secrets.md) | OpenBao login, secret lookup pattern, demo-only defaults, threat boundaries | -| [docs/inventories.md](docs/inventories.md) | Repository layout, roles origin, inventory topology, new-tenant walkthrough | -| [docs/ansible.md](docs/ansible.md) | Playbooks (`site.yml`), service parameters, variable cheat sheet | -| [docs/testing.md](docs/testing.md) | Static checks, inventory resolution, smoke test/dry run before the deploy | - -## Repository structure - -```text -reference-ansible/ -├── Makefile # deploy targets, OIDC login, OBJC fork workaround -├── ansible.cfg # collections_path, remote_user=root, hashi_vault auth -├── requirements.yml # community.hashi_vault + digitalboard.core (Git) -├── Vagrantfile # local test VMs -├── playbooks/ -│ └── site.yml # the only playbook — play sequence of all services -├── scripts/ -│ └── bao-seed.sh # seed/merge OpenBao secrets per inventory (idempotent) -├── docs/ # in-depth documentation (see table above) -├── collections/ # ← installed by `make install`, gitignored -│ └── ansible_collections/digitalboard/core/roles/ # 🔑 the roles live here -└── inventories/ - ├── demo-gymburgdorf/ # reference tenant — template for new tenants - │ ├── hosts.yml # hosts + group topology - │ ├── group_vars/ # all/ · traefik_servers/ · backend_servers/ - │ └── host_vars/ # reverseproxy/ · application/ · storage/ - ├── demo-mbazürich/ # demo tenant - ├── demo-phbern/ # demo tenant - └── vagrant/ # local test inventory (incompatible topology) -``` - -> **No `roles/` in the repository root** — all roles come from -> `digitalboard.core` and are installed via `make install` into -> `./collections/`. Plays reference them by FQCN -> `digitalboard.core.`. Details: -> [docs/inventories.md](docs/inventories.md#repo-layout-and-role-origin). - -## Quick Start - -> **First time here?** Prerequisites (WKS account, OIDC access, SSH key, -> VPN), tool setup, and the first deploy step by step: -> **[docs/getting_started.md](docs/getting_started.md)**. +### Secrets +Secrets are managed using [OpenBao](https://bao.digitalboard.ch). +The bao CLI needs to be installed. e.g `sudo pacman -S openbao python-hvac` +Authenticate and export token before running playbooks: ```bash -make install # collections into ./collections/ - -# Log in to OpenBao — in the SAME shell as the deploy. -# Full login flow + make-bao caveat: docs/secrets.md export BAO_ADDR=https://bao.digitalboard.ch bao login -method=oidc -path=Digitalboard export VAULT_TOKEN=$(bao print token) - -make ping_demo # smoke test against all demo inventories -make deploy_site_demo_gymburgdorf # single demo site -make deploy_site_demo # all three demo sites -``` - -Login details and the `make bao` caveat: [docs/secrets.md](docs/secrets.md#openbao-login). -Prerequisites, all make targets, and the deploy flow: -[docs/operations.md](docs/operations.md). Invoking Ansible directly -(`--limit`, `--check`): [docs/ansible.md § Running Ansible](docs/ansible.md#running-ansible). - -## Available playbooks - -The only playbook is [playbooks/site.yml](playbooks/site.yml) — a -sequence of plays, each applying one `digitalboard.core` role to a host -group (base, traefik, garage, authentik, authentik_outpost_ldap, -nextcloud, collabora, drawio, send, opnform, homarr, bookstack, …). -Which plays take effect in an inventory is governed solely by group -membership in `hosts.yml`. Full play table and all service parameters: -**[docs/ansible.md](docs/ansible.md)**. - -## Inventories - -| Inventory | Purpose | -| --- | --- | -| [`inventories/demo-gymburgdorf/`](inventories/demo-gymburgdorf/) | Demo tenant — recommended as a template for new tenants, see [docs/inventories.md](docs/inventories.md#walkthrough-creating-a-new-demo-tenant) | -| [`inventories/demo-mbazürich/`](inventories/demo-mbazürich/) | Demo tenant | -| [`inventories/demo-phbern/`](inventories/demo-phbern/) | Demo tenant | -| [`inventories/vagrant/`](inventories/vagrant/) | Local test VMs; **incompatible group topology** compared to the demo inventories | +``` \ No newline at end of file diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 4071672..0000000 --- a/docs/README.md +++ /dev/null @@ -1,29 +0,0 @@ - -# Documentation — `reference-ansible` - -Entry point for this repository's in-depth documentation. The -[`demo-gymburgdorf`](../inventories/demo-gymburgdorf/) inventory serves -as a running example throughout. - -> **Demo-only.** All role defaults (passwords, tokens, RPC secrets) are -> insecure and intended exclusively for demo setups. See -> [secrets.md § Demo-Only-Defaults](secrets.md#demo-only-defaults--must-be-overridden). - -## Table of contents - -| Document | Content | -| --- | --- | -| [getting_started.md](getting_started.md) | Prerequisites (access, tools), first deploy step by step | -| [operations.md](operations.md) | Setup, prerequisites, deploy flow, smoke test, known gaps | -| [secrets.md](secrets.md) | OpenBao login, secret lookup pattern, demo-only defaults, threat boundaries | -| [inventories.md](inventories.md) | Repository layout, roles origin, inventory topology, new-tenant walkthrough | -| [ansible.md](ansible.md) | Playbooks (`site.yml`), per-service parameters, variable cheat sheet | -| [testing.md](testing.md) | Static checks, inventory resolution, smoke test/dry run before the deploy | - -## Quick links - -- **First time here?** → [getting_started.md](getting_started.md) -- **Create a new tenant** → [inventories.md § Walkthrough](inventories.md#walkthrough-creating-a-new-demo-tenant) -- **Which variable goes where?** → [ansible.md § Variable cheat sheet](ansible.md#variable-cheatsheet) -- **Store a secret in Bao** → [secrets.md § Secret pattern](secrets.md#secret-pattern-bao-lookup) -- **Run a deploy** → [operations.md § Deploy](operations.md#deploy) diff --git a/docs/ansible.md b/docs/ansible.md deleted file mode 100644 index fdf1263..0000000 --- a/docs/ansible.md +++ /dev/null @@ -1,198 +0,0 @@ - -# Playbooks & Parameters - -[← Documentation index](README.md) - -Central reference: which plays [playbooks/site.yml](../playbooks/site.yml) -runs, which service parameters are relevant per role, and where they are -located in the inventory. Example used throughout: `demo-gymburgdorf`. - -## Playbook `site.yml` - -The only playbook is [playbooks/site.yml](../playbooks/site.yml). It -consists of a sequence of plays, each applying one role from -`digitalboard.core` to a host group. All plays run with -`become: yes`. Plays whose group has no members in an inventory run as a -**no-op**. - -| # | Play / role | `hosts:` | Target in `demo-gymburgdorf`? | -| --- | --- | --- | --- | -| 1 | `digitalboard.core.base` | `all_servers` | ✅ all 4 hosts | -| 2 | `digitalboard.core.traefik` | `traefik_servers` | ✅ all 4 (dmz on `reverseproxy`, otherwise backend) | -| 3 | `digitalboard.core.httpbin` | `httpbin_servers` | — no-op | -| 4 | `digitalboard.core.389ds` | `ds389_servers` | — no-op | -| 5 | `digitalboard.core.keycloak` | `keycloak_servers` | — no-op | -| 6 | `digitalboard.core.garage` | `garage_servers` | ✅ `storage` | -| 7 | `digitalboard.core.collabora` | `collabora_servers` | ✅ `application` | -| 8 | `digitalboard.core.authentik` | `authentik_servers` | ✅ `application` | -| 9 | `digitalboard.core.authentik_outpost_ldap` | `authentik_outpost_ldap_servers` | ✅ `application` | -| 10 | `digitalboard.core.nextcloud` | `nextcloud_servers` | ✅ `application` | -| 11 | `digitalboard.core.drawio` | `drawio_servers` | ✅ `application` | -| 12 | `digitalboard.core.send` | `send_servers` | ✅ `application` | -| 13 | `digitalboard.core.opnform` | `opnform_servers` | ✅ `application` | -| 14 | `digitalboard.core.homarr` | `homarr_servers` | ✅ `application` | -| 15 | `digitalboard.core.bookstack` | `bookstack_servers` | ✅ `application` | -| 16 | `digitalboard.core.opencloud` | `opencloud_servers` | — no-op (no group) | - -> Which plays take effect for a tenant is controlled **solely through -> group membership** in `hosts.yml`. A service becomes active as soon as -> its `_servers` group contains a host and a matching -> `host_vars//.yml` exists. - -## Running Ansible - -**Prerequisite:** collections installed (`make install`) and logged in -to OpenBao in the **same shell** (`VAULT_TOKEN` set) — without a token, -the `community.hashi_vault` lookups fail. Login procedure: -[secrets.md § OpenBao login](secrets.md#openbao-login). -Initial setup step by step: [getting_started.md](getting_started.md). - -### Via Makefile (recommended) - -```bash -make ping_demo # Smoke test (ping) against all demo inventories -make deploy_site_demo_gymburgdorf # single demo site -make deploy_site_demo # all three demo sites in sequence -``` - -The Make targets encapsulate the full `ansible-playbook` invocation -including `--diff` and the macOS fork env var. All targets: -[operations.md § Makefile reference](operations.md#makefile-reference). - -### Direct `ansible-playbook` invocation - -When you need flags that the targets do not set: - -```bash -# Full deploy of an inventory -ansible-playbook playbooks/site.yml \ - -i inventories/demo-gymburgdorf/hosts.yml --diff - -# Only one host (e.g. just the application machine) -ansible-playbook playbooks/site.yml \ - -i inventories/demo-gymburgdorf/hosts.yml --limit application - -# Dry run without changes -ansible-playbook playbooks/site.yml \ - -i inventories/demo-gymburgdorf/hosts.yml --check --diff -``` - -> Because service selection runs through the groups in `hosts.yml` (not -> through tags), `--limit ` is the usual way to narrow down a -> deploy. `--check` is only of limited value with the Docker Compose-based -> roles — some tasks report "changed" because they only learn the actual -> container state at runtime. - -Deploy flow and play order: [operations.md § Deploy](operations.md#deploy). - -## Where parameters belong - -| Variable group | File in `demo-gymburgdorf/` | Why | -| --- | --- | --- | -| `vault_addr`, `vault_mount` | `group_vars/all/vault.yml` | Bao endpoint applies site-wide | -| `docker_registry_mirrors` | `group_vars/all/docker.yml` | Pulls from mirror on all hosts | -| `traefik_acme_*`, `traefik_use_ssl`, `traefik_cert_mode`, `traefik_log_level` | `group_vars/traefik_servers/traefik.yml` | applies to all Traefik instances (dmz + backend) | -| `traefik_mode: backend` | `group_vars/backend_servers/traefik.yml` | default for app + storage | -| `traefik_mode: dmz`, `traefik_dmz_exposed_services` | `host_vars/reverseproxy/traefik.yml` | host-specific override, only meaningful there | -| `nextcloud_*`, `authentik_*`, `collabora_*`, `drawio_*`, `send_*`, `opnform_*`, `homarr_*`, `bookstack_*` | `host_vars/application/.yml` | service runs on `application` | -| `garage_*` | `host_vars/storage/garage.yml` | service runs on `storage` | -| Secrets (passwords, tokens, keys) | inline var with `lookup('community.hashi_vault.hashi_vault', …)` | single source of truth via Bao, see [secrets.md](secrets.md) | - -## Service parameters in detail - -Complete variable lists are in the `defaults/main.yml` of the respective -role in `digitalboard.core`. Below are the parameters maintained in the -demo inventories per service — as guidance on which fields a new tenant -typically needs to set. - -### traefik - -| Variable | Example / default | Purpose | -| --- | --- | --- | -| `traefik_mode` | `dmz` \| `backend` | Provider mode: `dmz` = file provider (public-facing, no Docker socket), `backend` = docker provider (auto-discovery via container labels) | -| `traefik_cert_mode` | `acme` \| `selfsigned` | Certificate source | -| `traefik_use_ssl` | `true` | TLS active | -| `traefik_ssl_email` | `hostmaster@digitalboard.ch` | ACME contact | -| `traefik_log_level` | `DEBUG` (role default `INFO`) | reduce for prod | -| `traefik_network` | `proxy` | Docker network for backend mode | -| `traefik_acme_dns_zone` | `demo-gymb._acme.digitalboard.ch` | RFC2136 update zone | -| `traefik_acme_dns_nameserver` | from Bao / `172.16.9.169` (DMZ override) | TSIG update target | -| `traefik_acme_tsig_algorithm` / `_key` / `_secret` | `hmac-sha256` / Bao | TSIG signature | -| `traefik_acme_tcp_only` | `true` | force DNS lookups over TCP/53 | -| `traefik_acme_disable_ans_checks` | `true` (DMZ only) | skip NS propagation poll | -| `traefik_dmz_exposed_services` | list (DMZ) | which backends the DMZ Traefik routes | - -### authentik (IdP — OIDC + LDAP outpost backend) - -| Variable | Purpose | -| --- | --- | -| `authentik_domains` | public FQDNs (`auth.gymb.souveredu.ch`) | -| `authentik_host_rewrite_domains` | internal `*.int.*` names for LAN server-to-server | -| `authentik_secret_key`, `authentik_postgres_password` | Bao lookup | -| `authentik_ldap_apps`, `authentik_ldap_outpost` | LDAP app + outpost definition (base_dn, token) | -| `authentik_proxy_apps` | ForwardAuth apps (slug, external/internal_host, allowed_groups) | - -### authentik_outpost_ldap - -| Variable | Purpose | -| --- | --- | -| `authentik_outpost_ldap_host` | internal Authentik host (`https://auth.int.…`) | -| `authentik_outpost_ldap_token` | outpost token (Bao, identical to `authentik.ldap_outpost_token`) | - -### nextcloud - -| Variable | Purpose | -| --- | --- | -| `nextcloud_image` | image tag (pin to patched version) | -| `nextcloud_domains` | first entry = canonical public FQDN, further `*.int.*` | -| `nextcloud_admin_user` / `_password`, `nextcloud_postgres_password` | admin + DB (Bao) | -| `nextcloud_use_s3_storage`, `nextcloud_s3_*` | S3 primary storage via Garage (key/secret via `garage_credentials` lookup) | -| `nextcloud_enable_collabora`, `nextcloud_collabora_domain` / `_public_domain` | WOPI integration | -| `nextcloud_enable_drawio`, `nextcloud_drawio_url` | Draw.io integration | -| `nextcloud_oidc_providers` | OIDC login via Authentik (discovery_url, client_id/secret) | -| `nextcloud_ldap_enabled`, `nextcloud_ldap_config` | LDAP backend against Authentik outpost | -| `nextcloud_apps_to_install` | app list (groupfolders, richdocuments, spreed, user_ldap, …) | -| `nextcloud_allow_local_remote_servers`, `nextcloud_extra_hosts` | LAN-only routing for server-to-server calls | - -### collabora - -| Variable | Purpose | -| --- | --- | -| `collabora_domains` | public + internal FQDN | -| `collabora_allowed_domains`, `collabora_frame_ancestors` | allowed WOPI hosts / iframe embedding | - -### drawio - -| Variable | Purpose | -| --- | --- | -| `drawio_domain`, `drawio_extra_domains` | public + internal FQDN | -| `drawio_authentik_forward_auth`, `_url` | access protection via Authentik ForwardAuth | - -### garage (S3 object store) - -| Variable | Purpose | -| --- | --- | -| `garage_s3_domains` | first entry = public S3 FQDN, further `*.int.*` | -| `garage_webui_domain`, `garage_webui_enabled` | admin WebUI | -| `garage_webui_authentik_forward_auth`, `_url` | WebUI behind Authentik (admins only) | -| `garage_rpc_secret`, `garage_admin_token`, `garage_metrics_token` | Bao lookup | -| `garage_bootstrap_*` | single-node cluster bootstrap (zone, capacity) | -| `garage_s3_keys` | keys + buckets + permissions (e.g. `nextcloud`) | - -### send / opnform / homarr / bookstack - -Same pattern: `_domain`/`_domains` (+ `*.int.*`), -`_base_url`, admin credentials and app keys via Bao lookup, -plus OIDC integration with Authentik (`_oidc_*`: issuer, client_id, -client_secret, admin_group). For the concrete fields, see the respective -`host_vars/application/.yml`. - -## Variable cheatsheet - -Short form of the location table above — "which variable goes where": - -- **Site-wide** → `group_vars/all/` (Bao endpoint, Docker mirror) -- **All Traefik** → `group_vars/traefik_servers/` -- **app + storage** → `group_vars/backend_servers/` -- **Single host** → `host_vars//.yml` -- **Secrets** → always Bao lookup, never plaintext (see [secrets.md](secrets.md)) diff --git a/docs/getting_started.md b/docs/getting_started.md deleted file mode 100644 index 6c06376..0000000 --- a/docs/getting_started.md +++ /dev/null @@ -1,93 +0,0 @@ - -# Getting Started - -[← Documentation index](README.md) - -From zero to your first deploy. This page walks through prerequisites, -setup, and the first Ansible run. Deeper details are linked along the way. - -## Prerequisites - -### Access (to be set up out-of-band) - -- **WKS account with OIDC access to OpenBao.** The login runs via - `bao login -method=oidc -path=Digitalboard`. Without an authorized - account, authentication fails — and without a token there is no - secret lookup, hence no deploy. -- **Bao policy / mount read.** The account needs **Read** on the - mount of the target inventory (e.g. `demo-gymburgdorf/data/*`). Which - paths an inventory reads is documented in the `host_vars/.../.yml` - (see [secrets.md § Secret pattern](secrets.md#secret-pattern-bao-lookup)). -- **SSH key on the target hosts.** The hosts are provisioned as `root` - (`ansible_user: root`, no bastion/jump host). Your own public key - must be placed out-of-band as `root` on the hosts. -- **Network access (VPN).** `bao.digitalboard.ch` and the host networks - (`172.16.9.0/24` DMZ, `172.16.19.0/24` backend) are not publicly - reachable — access requires VPN/network access into the Digitalboard network. - -### Tools on the control node - -- `ansible` (Core ≥ 2.15) — `ansible --version` to check -- `bao` CLI ([OpenBao](https://openbao.org/)) — e.g. - `sudo pacman -S openbao python-hvac` (Arch) or via Homebrew -- `python-hvac` (for `community.hashi_vault` lookups) -- On macOS: `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` (set in the - [Makefile](../Makefile)) — without this env var, Ansible forks crash - on the first Bao lookup, because the Objective-C runtime is not - fork-safe. - -## 1. Clone the repo and install collections - -```bash -git clone https://git.digitalboard.ch/Digitalboard/reference-ansible -cd reference-ansible -make install # community.hashi_vault + digitalboard.core into ./collections/ -``` - -There is **no** `roles/` directory in the repo — all roles come from -the `digitalboard.core` collection. See -[inventories.md § Repo layout](inventories.md#repo-layout-and-role-origin). - -## 2. Log in to OpenBao - -The login must happen in the **same shell** in which -`ansible-playbook` then runs — details and the `make bao` caveat in -[secrets.md § OpenBao login](secrets.md#openbao-login): - -```bash -export BAO_ADDR=https://bao.digitalboard.ch -bao login -method=oidc -path=Digitalboard -export VAULT_TOKEN=$(bao print token) -``` - -## 3. Check connectivity (smoke test) - -```bash -make ping_demo # ping module against all three demo inventories -``` - -If a host does not respond, it is usually due to the SSH key -(prerequisite above) or missing network access (VPN). - -## 4. Run Ansible (deploy) - -```bash -make deploy_site_demo_gymburgdorf # single demo site -``` - -At its core, `make` only calls `ansible-playbook` — the equivalent -direct invocation: - -```bash -ansible-playbook playbooks/site.yml \ - -i inventories/demo-gymburgdorf/hosts.yml --diff -``` - -All variants (direct invocation, `--limit`, `--tags`, check mode) and -the make targets are documented in [ansible.md § Running Ansible](ansible.md#running-ansible). - -## Next steps - -- **What happens during a deploy?** → [ansible.md § Playbook](ansible.md#playbook-siteyml) -- **Create a new tenant** → [inventories.md § Walkthrough](inventories.md#walkthrough-creating-a-new-demo-tenant) -- **Store secrets in Bao** → [secrets.md](secrets.md) diff --git a/docs/inventories.md b/docs/inventories.md deleted file mode 100644 index ff3a903..0000000 --- a/docs/inventories.md +++ /dev/null @@ -1,179 +0,0 @@ - -# Repo layout & inventories - -[← Documentation index](README.md) - -## Repo layout and role origin - -```text -reference-ansible/ -├── Makefile # deploy targets, OIDC login, OBJC fork workaround -├── ansible.cfg # collections_path, remote_user=root, hashi_vault auth_method=token -├── requirements.yml # community.hashi_vault + digitalboard.core (Git) -├── playbooks/site.yml # play sequence (see ansible.md) -├── scripts/bao-seed.sh # seed/merge OpenBao secrets per inventory -├── docs/ # this documentation -├── collections/ # ← installed by `make install`, gitignored -│ └── ansible_collections/ -│ └── digitalboard/core/ -│ └── roles/ # 🔑 THE ROLES LIVE HERE, NOT in the repo root -└── inventories/ - ├── demo-gymburgdorf/ # reference inventory of this documentation - ├── demo-mbazürich/ - ├── demo-phbern/ - └── vagrant/ # local test inventory with its own topology -``` - -> **Important:** There is **no** `roles/` directory in the repo root. All -> roles come from the `digitalboard.core` collection (see -> [requirements.yml](../requirements.yml)), installed via -> `make install` into `./collections/`. Plays reference them by -> FQCN `digitalboard.core.`. - -## Available inventories - -| Inventory | Purpose | -| --- | --- | -| [`demo-gymburgdorf/`](../inventories/demo-gymburgdorf/) | Demo tenant — **recommended as the template for new tenants** | -| [`demo-mbazürich/`](../inventories/demo-mbazürich/) | Demo tenant | -| [`demo-phbern/`](../inventories/demo-phbern/) | Demo tenant | -| [`vagrant/`](../inventories/vagrant/) | local test VMs; **incompatible group topology** with the demo inventories | - -## Inventory topology (`demo-gymburgdorf`) - -```mermaid -flowchart LR - classDef dmz fill:#fee2e2,stroke:#991b1b,color:#000 - classDef app fill:#dcfce7,stroke:#166534,color:#000 - classDef stor fill:#dbeafe,stroke:#1e40af,color:#000 - classDef turn fill:#fef9c3,stroke:#854d0e,color:#000 - - subgraph ALL["group: all_servers"] - direction LR - subgraph DMZ["DMZ 172.16.9.0/24"] - RP["reverseproxy
172.16.9.111
traefik_mode: dmz"]:::dmz - TURN["turn
172.16.9.112
(no role in site.yml yet)"]:::turn - end - subgraph BE["Backend 172.16.19.0/24
group: backend_servers"] - APP["application
172.16.19.101
traefik_mode: backend
+ authentik, authentik_outpost_ldap,
nextcloud, collabora, drawio, …"]:::app - ST["storage
172.16.19.102
traefik_mode: backend
+ garage (S3)"]:::stor - end - end - - RP -.HTTPS in, HTTP out.-> APP - RP -.HTTPS in, HTTP out.-> ST -``` - -**Group memberships (from [hosts.yml](../inventories/demo-gymburgdorf/hosts.yml)):** - -| Group | Members | Purpose | -| --- | --- | --- | -| `all_servers` | `reverseproxy`, `application`, `storage`, `turn` | base role for all hosts | -| `traefik_servers` | `children: all_servers` (all 4 hosts) | Traefik everywhere; DMZ/backend via `traefik_mode` | -| `backend_servers` | `application`, `storage` | sets `traefik_mode: backend` via group_var | -| `garage_servers` | `storage` | single-host wrapper for the Garage role | -| `nextcloud_servers`, `collabora_servers`, `drawio_servers`, `authentik_servers`, `authentik_outpost_ldap_servers`, `send_servers`, `opnform_servers`, `homarr_servers`, `bookstack_servers` | only `application` each | single-host wrappers | - -> **Difference from the `vagrant` inventory:** `vagrant` structures -> Traefik differently — via the children groups `traefik_servers_dmz` and -> `traefik_servers_backend` instead of via `backend_servers` + -> `host_vars` override. The two topologies are **structurally -> incompatible**; a 1:1 mapping is not possible. For new tenants, therefore, -> take `demo-gymburgdorf` as the template. - -## Standard folder structure of an inventory entry - -A fully built-out inventory follows this layout (example -`demo-gymburgdorf`). Currently only this inventory is built out; -`demo-mbazürich` and `demo-phbern` so far contain only `hosts.yml`. - -```text -inventories/demo-/ -├── hosts.yml # REQUIRED — hosts, IPs, group topology -├── group_vars/ -│ ├── all/ -│ │ ├── vault.yml # REQUIRED — vault_addr, vault_mount (Bao) -│ │ ├── ansible.yml # ansible_python_interpreter etc. -│ │ └── docker.yml # docker_registry_mirrors -│ ├── traefik_servers/ -│ │ └── traefik.yml # ACME/TSIG, TLS — applies to ALL Traefik instances -│ └── backend_servers/ -│ └── traefik.yml # traefik_mode: backend (default for app + storage) -└── host_vars/ - ├── reverseproxy/ - │ └── traefik.yml # traefik_mode: dmz + DMZ-specific ACME overrides - ├── application/ - │ ├── main.yml # comment only: which services run here - │ ├── traefik.yml # traefik_dmz_exposed_services (what the DMZ routes) - │ └── .yml # one file per service (nextcloud, authentik, …) - └── storage/ - ├── main.yml # same as above - ├── traefik.yml # traefik_extra_hosts + traefik_dmz_exposed_services - └── garage.yml # service vars for garage -``` - -**Conventions:** - -- **`hosts.yml` is the only hard required file.** Vars are - optional — if one is missing, the role defaults from - `digitalboard.core` take effect. A new inventory therefore starts minimally with - only `hosts.yml` (just like `demo-mbazürich`/`demo-phbern`). -- **`group_vars/all/vault.yml`** is effectively required as soon as - Bao lookups are supposed to work — without `vault_mount`/`vault_addr` the - secret lookups fail. -- **One file per service** under `host_vars//.yml`. The - file name is free (Ansible loads all YAMLs in the directory); by - convention it is named like the role. Which variables belong where: - [ansible.md § Where parameters belong](ansible.md#where-parameters-belong). -- **`main.yml` per host** is pure documentation — a comment indicating which - services run on the host. Carries no productive vars. -- **`host_vars//traefik.yml`** declares via - `traefik_dmz_exposed_services` which local services the - DMZ Traefik should make reachable from outside. The DMZ reads this - list via `hostvars[]` and renders its routers from it. A new - service exposed externally = a new entry here. Mechanics: - [ansible.md § traefik](ansible.md#traefik). - -## Walkthrough: Creating a new demo tenant - -Recommended template: **`demo-gymburgdorf`** (not `vagrant`, because its -group topology is incompatible). - -1. **Copy the inventory:** - - ```bash - cp -r inventories/demo-gymburgdorf inventories/demo- - ``` - -2. **Adjust `hosts.yml`:** IPs, hostnames per host. - -3. **`group_vars/all/vault.yml`** — set `vault_mount` to the new - tenant mount (`demo-`). - -4. **`group_vars/traefik_servers/traefik.yml`** — - point `traefik_acme_dns_zone` and the `acme-tsig` lookup paths to the - new zone / the new Bao path. - -5. Go through **`host_vars/application/*.yml`** and **`host_vars/storage/*.yml`**: - FQDNs to the new domain pattern (e.g. - `*..souveredu.ch`), Bao lookup paths to `demo-/data/…`. - -6. **Prepare OpenBao** (out-of-band, not via Ansible): - - Create a new KV-v2 mount `demo-`. - - Write secrets: `acme-tsig`, `authentik`, `nextcloud`, - `garage`, … — conveniently via `make seed_bao_` (see - [scripts/bao-seed.sh](../scripts/bao-seed.sh) and - [secrets.md § Demo-Only-Defaults](secrets.md#demo-only-defaults--must-be-overridden)). - - Policy for the deploy token: read on `demo-/data/*`. - -7. **DNS:** Create the TSIG update zone (`demo-._acme.digitalboard.ch`) at - `ns1.digitalboard.ch`, CNAMEs - `_acme-challenge.*..` pointing there. - -8. **Makefile** — add a new target modeled on - `deploy_site_demo_gymburgdorf` and add it to `deploy_site_demo`; - likewise a `seed_bao_` target. - -9. **Smoke test:** `ansible all -i inventories/demo-/hosts.yml -m ping`. - -10. **Deploy:** Bao login + `make deploy_site_demo_`. diff --git a/docs/operations.md b/docs/operations.md deleted file mode 100644 index 3176312..0000000 --- a/docs/operations.md +++ /dev/null @@ -1,136 +0,0 @@ - -# Setup & operations - -[← Documentation index](README.md) - -## Prerequisites (control node) - -- `ansible` (Core ≥ 2.15) -- `bao` CLI ([OpenBao](https://openbao.org/)) — e.g. - `sudo pacman -S openbao python-hvac` (Arch) or via Homebrew -- `python-hvac` (for `community.hashi_vault` lookups) -- On macOS: `OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES` (set in the - [Makefile](../Makefile); without this env var, Ansible forks crash - on the first `community.hashi_vault` lookup, because the - Objective-C runtime is not fork-safe) - -## Initial setup - -```bash -git clone -cd reference-ansible -make install # Galaxy + digitalboard.core into ./collections/ -``` - -`make install` installs `community.hashi_vault` and the -`digitalboard.core` collection (Git, see [requirements.yml](../requirements.yml)) -into `./collections/`. There is **no** `roles/` directory in the -repo root — all roles come from the collection, see -[inventories.md § Repo layout](inventories.md#repo-layout-and-role-origin). - -## Secrets (OpenBao) - -Before every deploy, authenticate to OpenBao in the **same shell**. The -full login flow, the `make bao` caveat, the lookup pattern, and -tenant isolation are documented in **[secrets.md](secrets.md)**. - -## Smoke test - -```bash -make ping_demo # pings all three demo inventories (ping module) -``` - -## Deploy - -```bash -make deploy_site_demo_gymburgdorf # single demo site -make deploy_site_demo_mbazürich -make deploy_site_demo_phbern -make deploy_site_demo # all three in sequence -``` - -`--diff` is set in the Gymburgdorf target → changes are visible per task. -The play order and which plays run as no-ops: -see [ansible.md § Playbooks](ansible.md#playbook-siteyml). - -## Makefile reference - -The [Makefile](../Makefile) bundles setup, secret handling, and deploy. -It defines no variables for passing in except `DRY_RUN` (for the -`seed_bao_*` targets) — control is via the chosen target. - -### Exported env vars (apply to all targets) - -| Variable | Value | Purpose | -| --- | --- | --- | -| `BAO_ADDR` | `https://bao.digitalboard.ch` | OpenBao endpoint for `bao` and `community.hashi_vault` calls | -| `OBJC_DISABLE_INITIALIZE_FORK_SAFETY` | `YES` | macOS fork safety: without this var, Ansible forks crash on the first `hashi_vault` lookup, because the Objective-C runtime is not fork-safe | - -> Both are set via `export` at the top of the Makefile and thus -> inherited by every target shell process — regardless of which target runs. - -### Setup & secrets - -| Target | Effect | -| --- | --- | -| `make install` | `ansible-galaxy collection install -r requirements.yml -p collections` — installs `community.hashi_vault` + `digitalboard.core` into `./collections/` | -| `make bao` | `bao login -method=oidc -path=Digitalboard role=default` + sets `VAULT_TOKEN` via `$(eval …)`. ⚠️ The token only lives **within this single `make` invocation** — see caveat below | -| `make seed_bao_gymburgdorf` | Seed/merge OpenBao secrets for `demo-gymburgdorf` via [scripts/bao-seed.sh](../scripts/bao-seed.sh). Idempotent: existing keys remain, only missing ones are generated | -| `make seed_bao_mbazürich` | same for `demo-mbazürich` | -| `make seed_bao_phbern` | same for `demo-phbern` | - -> The `seed_bao_*` targets understand `DRY_RUN=1` — shows the diff without -> writing: `make seed_bao_gymburgdorf DRY_RUN=1`. Requirement: -> `bao`, `jq`, `openssl` in `$PATH` and a valid `VAULT_TOKEN`. - -### Smoke test & deploy - -| Target | Effect | -| --- | --- | -| `make ping_demo` | `ansible … -m ping` against all three demo inventories in sequence; failures of individual hosts do not abort (`\|\| true`) | -| `make deploy_site_demo_gymburgdorf` | `ansible-playbook playbooks/site.yml -i …/demo-gymburgdorf/hosts.yml --diff` | -| `make deploy_site_demo_mbazürich` | same for `demo-mbazürich` — **without** `--diff` | -| `make deploy_site_demo_phbern` | same for `demo-phbern` — **without** `--diff` | -| `make deploy_site_demo` | calls the three `deploy_site_demo_*` targets in sequence | - -> **Inconsistency:** Only the Gymburgdorf target sets `--diff`. For -> `mbazürich` and `phbern` you do not see the task changes — if -> needed, invoke directly with `ansible-playbook … --diff`, see -> [ansible.md § Running Ansible](ansible.md#running-ansible). - -### Token caveat (`make bao`) - -`make bao` alone is **not** enough for a deploy: each `make` target -runs in its own shell, the `VAULT_TOKEN` set there only lives -during `make bao` itself and is already gone in the next `make deploy_…`. -Two working approaches: - -```bash -# Variant A — log in manually in the active shell (survives multiple make invocations) -export BAO_ADDR=https://bao.digitalboard.ch -bao login -method=oidc -path=Digitalboard -export VAULT_TOKEN=$(bao print token) -make deploy_site_demo_gymburgdorf - -# Variant B — chain both as ONE make invocation (token lives for the chain) -make bao deploy_site_demo_gymburgdorf -``` - -Login details and the secret pattern: [secrets.md](secrets.md#openbao-login). - -## Known gaps and trade-offs - -- **`opencloud` in `demo-gymburgdorf`:** Play present, but no - `opencloud_servers` group — runs as a no-op. If needed, add a group + - `host_vars`, see [inventories.md](inventories.md#walkthrough-creating-a-new-demo-tenant). -- **`turn` host:** defined in the DMZ, but no STUN/TURN role in - [playbooks/site.yml](../playbooks/site.yml) — provisioned only via `base` + - `traefik`. -- **Idempotency:** Roles are Docker-Compose-based; re-runs can - trigger container restarts when Compose inputs change. No - rollback mechanism — on failure, roll back manually. -- **TLS renewal:** handled internally by Traefik via ACME, no external - renew cron in the repo. -- **CI/testing:** currently not in the repo; smoke test via `make ping_demo`. -- **Logging:** `traefik_log_level: DEBUG` in `demo-gymburgdorf` and - `vagrant` (role default `INFO`) — reduce before adapting to prod. diff --git a/docs/secrets.md b/docs/secrets.md deleted file mode 100644 index 649cc59..0000000 --- a/docs/secrets.md +++ /dev/null @@ -1,99 +0,0 @@ - -# Secrets & security - -[← Documentation index](README.md) - -> This repo is explicitly intended for **demo setups**. All -> default values in the roles are insecure and are overridden in -> `demo-*` inventories via Bao lookups or host_vars. - -## OpenBao login - -A prerequisite is a WKS account with OIDC access to OpenBao and a -read policy on the inventory mount — see -[getting_started.md § Vorbedingungen](getting_started.md#prerequisites). - -Before each deploy, authenticate in **the same shell** in which -`ansible-playbook` then runs: - -```bash -export BAO_ADDR=https://bao.digitalboard.ch -bao login -method=oidc -path=Digitalboard -export VAULT_TOKEN=$(bao print token) -``` - -> ⚠️ `make bao` alone is **not** enough — every `make` target runs in -> a new shell, and the `VAULT_TOKEN` set there lives only during -> `make bao` itself. Either run the three commands above manually -> or chain `make bao deploy_site_demo_gymburgdorf` as **one** call -> — otherwise the deploy has no token. - -## Secret pattern (Bao lookup) - -Secrets are never stored in plaintext, but read from -OpenBao at runtime: - -```yaml -# host_vars/.../.yml — one lookup per service path, -# individual keys as properties: -_nextcloud: "{{ lookup('community.hashi_vault.hashi_vault', - vault_mount + '/data/nextcloud', url=vault_addr) }}" -nextcloud_admin_password: "{{ _nextcloud.admin_password }}" -nextcloud_postgres_password: "{{ _nextcloud.postgres_password }}" -``` - -- `vault_mount` and `vault_addr` come from - [group_vars/all/vault.yml](../inventories/demo-gymburgdorf/group_vars/all/vault.yml). -- KV-v2 paths need an explicit `/data/` in the path — Ansible does not - resolve this on its own. -- `vault_mount` is unique per inventory (`demo-gymburgdorf`, - `demo-phbern`, …) → tenant isolation in Bao via mount + policy. - -Secrets are seeded idempotently with [scripts/bao-seed.sh](../scripts/bao-seed.sh) (or -`make seed_bao_`): existing keys remain, -only missing ones are generated. OIDC client secrets are kept in sync between -`/data/authentik` and the respective service secret. - -## Demo-only defaults — must be overridden - -These defaults in `digitalboard.core` are insecure. In every -**production-grade** deployment they must be overridden via a Bao lookup or host_var: - -| Variable | Default | Where to override | -| --- | --- | --- | -| `keycloak_admin_password` | `changeme` | host_vars `keycloak_servers` | -| `keycloak_postgres_password` | `changeme` | same as above | -| `authentik_secret_key` | `changeme-generate-a-random-string` | `host_vars/application/authentik.yml` | -| `authentik_postgres_password` | `changeme` | same as above | -| `nextcloud_admin_password` | `admin` | `host_vars/application/nextcloud.yml` | -| `nextcloud_postgres_password` | `changeme` | same as above | -| `nextcloud_s3_key` / `nextcloud_s3_secret` | `changeme` / `changeme` | same as above | -| `garage_webui_password` | `admin` | `host_vars/storage/garage.yml` | -| `garage_rpc_secret` | `0123…cdef` (64-hex constant) | same as above | -| `garage_admin_token` | identical to `rpc_secret` | same as above | -| `garage_metrics_token` | identical to `rpc_secret` | same as above | - -> **Convention:** Every value above **must** have a Bao lookup in -> `demo-*/host_vars/.../...yml` before the -> inventory counts as deployable. - -## Threat boundaries (status: demo) - -| Boundary | Status | Note | -| --- | --- | --- | -| DMZ ↔ backend (172.16.9 ↔ 172.16.19) | **plaintext HTTP** | auth bearer, OIDC code, session cookies travel unencrypted. Demo-ok; prod: mTLS or WireGuard overlay. | -| Host firewall | **missing** | The `base` role installs no UFW/nftables. Segmentation depends on the hypervisor/VLAN. | -| SSH | `ansible_user: root` | No bastion, no jump host. Key distribution out-of-band. | -| Authentik SPOF | **accepted** | IdP and SP services on the same host (`application`). Authentik outage = login outage including LDAP outpost. No break-glass path. | -| ACME TSIG key | Bao lookup | One TSIG key per demo zone (`acme_update_key_demo_gymb`), zone-isolated. Rotation manual. | -| Backup/DR | **out-of-scope** | Garage `replication_factor: 1`, no Postgres backup job, no Bao snapshot cron. | - -## Add for production adaptation - -- Host FW (extend the `base` role or a dedicated `firewall` role). -- mTLS or WireGuard between DMZ and backend. -- Authentik on a separate host, with a recovery admin token. -- Bao policies per inventory mount (read-only for the deploy token, - write-only for the bootstrap job). -- Backup cron for Postgres + Garage + Bao. -- SSH bastion + key rotation. diff --git a/docs/testing.md b/docs/testing.md deleted file mode 100644 index 9ded098..0000000 --- a/docs/testing.md +++ /dev/null @@ -1,93 +0,0 @@ - -# Testing - -[← Documentation index](README.md) - -> **Status:** This repo contains **no** automated test suite -> and **no** CI pipeline. It is the inventory/configuration -> layer — the testable logic (roles) lives in -> [`digitalboard.core`](https://git.digitalboard.ch/Digitalboard/digitalboard.core). -> Role tests (Molecule or similar) therefore belong in the core repo, not -> here. - -What is sensibly testable here are **inventory and playbook errors -before the actual deploy**. There are three levels for that — from fast and -risk-free to fully against the hosts. - -## 1. Static checks (no host access needed) - -No `VAULT_TOKEN`, no network access required: - -```bash -# YAML syntax of all inventory files -yamllint inventories/ - -# Ansible best-practice lint over the playbook -ansible-lint playbooks/site.yml - -# Playbook syntax + inventory parsing (does not yet resolve Bao lookups) -ansible-playbook playbooks/site.yml \ - -i inventories/demo-gymburgdorf/hosts.yml --syntax-check -``` - -`yamllint` and `ansible-lint` are not preconfigured in the repo — -they run with their defaults. If project-wide rules are desired, -a `.yamllint`/`.ansible-lint` in the repo root would be the place for it -(see [Open items](#open-items)). - -## 2. Inspect inventory resolution - -Shows the effectively merged variables per host — useful for seeing -precedence surprises (group_vars vs. host_vars) before the deploy. -Bao lookups are evaluated here, so `VAULT_TOKEN` is needed (see -[secrets.md](secrets.md#openbao-login)): - -```bash -# group/host structure as a tree -ansible-inventory -i inventories/demo-gymburgdorf/hosts.yml --graph - -# all vars of a host (merged) -ansible-inventory -i inventories/demo-gymburgdorf/hosts.yml --host application -``` - -## 3. Smoke test & dry run (against the hosts) - -Requires SSH access and `VAULT_TOKEN` — for prerequisites see -[getting_started.md](getting_started.md#prerequisites): - -```bash -# reachability of all demo hosts (ping module) -make ping_demo - -# dry run: shows what WOULD change, without writing -ansible-playbook playbooks/site.yml \ - -i inventories/demo-gymburgdorf/hosts.yml --check --diff -``` - -> `--check` is only of limited value with the Docker-Compose-based roles: -> some tasks report "changed" because they only know the real -> container state at runtime. As a plausibility check -> (does the playbook run through, are the vars correct?) it is still -> useful. More on the invocation variants: -> [ansible.md § Running Ansible](ansible.md#running-ansible). - -## Recommended pre-deploy workflow - -```bash -yamllint inventories/ && ansible-lint playbooks/site.yml # 1. static -ansible-playbook playbooks/site.yml -i /hosts.yml --syntax-check -make ping_demo # 2. reachability -ansible-playbook playbooks/site.yml -i /hosts.yml --check --diff # 3. dry run -# only then: real deploy -``` - -## Open items - -- **No CI** — lint/syntax checks run only manually. A Gitea/ - CI workflow that runs the static checks from level 1 on every push - would be the next step. -- **No lint configuration** — `yamllint`/`ansible-lint` run with - defaults; project-wide rules (`.yamllint`, `.ansible-lint`) are missing. -- **Role tests** — belong in - [`digitalboard.core`](https://git.digitalboard.ch/Digitalboard/digitalboard.core), - not in this repo. diff --git a/inventories/demo-gymburgdorf/group_vars/traefik_servers/traefik.yml b/inventories/demo-gymburgdorf/group_vars/traefik_servers/traefik.yml index de50728..c42dc79 100644 --- a/inventories/demo-gymburgdorf/group_vars/traefik_servers/traefik.yml +++ b/inventories/demo-gymburgdorf/group_vars/traefik_servers/traefik.yml @@ -2,7 +2,6 @@ _acme_tsig: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data traefik_use_ssl: true traefik_cert_mode: "acme" -traefik_ssl_email: "hostmaster@digitalboard.ch" traefik_log_level: DEBUG traefik_network: proxy @@ -12,9 +11,3 @@ traefik_acme_tsig_algorithm: "hmac-sha256" traefik_acme_tsig_key: "{{ _acme_tsig.tsig_key }}" traefik_acme_tsig_secret: "{{ _acme_tsig.tsig_secret }}" -# UDP/53 egress from the traefik container reaches ns1.digitalboard.ch -# unreliably (i/o timeouts on lego's recursive SOA pre-check), while -# TCP/53 to the same nameserver is open. Force lego to do its DNS -# lookups over TCP so the DNS-01 challenge can proceed. -traefik_acme_tcp_only: true - diff --git a/inventories/demo-gymburgdorf/host_vars/application/authentik.yml b/inventories/demo-gymburgdorf/host_vars/application/authentik.yml index 55d6a3a..aba8775 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/authentik.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/authentik.yml @@ -2,22 +2,10 @@ # Bao secret expected at /data/authentik with keys: # secret_key, postgres_password, admin_password, # ldap_outpost_token, -# nextcloud_oidc_secret, -# opnform_oidc_secret, homarr_oidc_secret, bookstack_oidc_secret +# nextcloud_oidc_secret _authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" -# Canonical public FQDN browsers and OIDC iss-claim use. -authentik_domains: - - "auth.gymb.souveredu.ch" - -# Internal FQDN for server-to-server calls (Nextcloud OIDC discovery, -# token, userinfo; LDAP outpost configuration pull). Traefik rewrites -# the Host header to `authentik_domains[0]` on these routers so authentik -# still emits issuer URLs against the public hostname — that keeps the -# iss claim matching what the browser sees while the traffic itself -# stays inside the LAN (the DMZ has no hairpin-NAT for the public IP). -authentik_host_rewrite_domains: - - "auth.int.gymb.souveredu.ch" +authentik_domain: "auth.gymb.souveredu.ch" authentik_secret_key: "{{ _authentik.secret_key }}" authentik_postgres_password: "{{ _authentik.postgres_password }}" @@ -32,49 +20,9 @@ authentik_ldap_outpost: name: "ldap-outpost" token: "{{ _authentik.ldap_outpost_token }}" config: - # 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/" + authentik_host: "https://auth.gymb.souveredu.ch/" log_level: "info" -# Proxy providers (ForwardAuth) — gate downstream services behind -# authentik. The embedded outpost (which authentik ships out of the box) -# hosts these providers under /outpost.goauthentik.io/auth/traefik on the -# canonical FQDN; the service-side traefik attaches a ForwardAuth -# middleware that talks to that endpoint. -authentik_proxy_apps: - - slug: drawio - name: Drawio - external_host: "https://draw.gymb.souveredu.ch" - internal_host: "http://drawio:8080" - allowed_groups: - - admins - flows: - authentication_slug: default-authentication-flow - authorization_slug: default-provider-authorization-implicit-consent - invalidation_slug: default-provider-invalidation-flow - - slug: garage-webui - name: "Garage S3 Console" - external_host: "https://console.s3.gymb.souveredu.ch" - internal_host: "http://garage-webui:3909" - allowed_groups: - - admins - flows: - authentication_slug: default-authentication-flow - authorization_slug: default-provider-authorization-implicit-consent - invalidation_slug: default-provider-invalidation-flow - -# Bind both proxy providers to authentik's built-in embedded outpost so -# we don't have to deploy a separate proxy outpost container. The -# embedded outpost listens on the same host:9000 as the authentik server -# and exposes /outpost.goauthentik.io/auth/traefik for ForwardAuth. -authentik_proxy_outposts: - - name: "authentik Embedded Outpost" - type: proxy - providers: - - drawio - - garage-webui - # OIDC clients authentik_oidc_apps: - slug: nextcloud @@ -89,52 +37,10 @@ authentik_oidc_apps: authorization_slug: default-provider-authorization-implicit-consent invalidation_slug: default-provider-invalidation-flow scopes: [openid, email, profile, offline_access] - - slug: opnform - name: OpnForm - client_id: opnform - client_secret: "{{ _authentik.opnform_oidc_secret }}" - redirect_uris: - - url: "https://forms.gymb.souveredu.ch/auth/authentik/callback" - matching_mode: strict - signing_key_name: "authentik Self-signed Certificate" - flows: - authorization_slug: default-provider-authorization-implicit-consent - invalidation_slug: default-provider-invalidation-flow - # No separate `groups` scope — authentik's default `profile` mapping - # already emits a `groups` claim built from request.user.groups, so - # OpnForm's admin-group mapping works without an extra scope. - scopes: [openid, email, profile] - - slug: homarr - name: Homarr - client_id: homarr - client_secret: "{{ _authentik.homarr_oidc_secret }}" - redirect_uris: - - url: "https://home.gymb.souveredu.ch/api/auth/callback/oidc" - matching_mode: strict - signing_key_name: "authentik Self-signed Certificate" - flows: - authorization_slug: default-provider-authorization-implicit-consent - invalidation_slug: default-provider-invalidation-flow - scopes: [openid, email, profile] - - slug: bookstack - name: BookStack - client_id: bookstack - client_secret: "{{ _authentik.bookstack_oidc_secret }}" - redirect_uris: - - url: "https://wiki.gymb.souveredu.ch/oidc/callback" - matching_mode: strict - signing_key_name: "authentik Self-signed Certificate" - flows: - authorization_slug: default-provider-authorization-implicit-consent - invalidation_slug: default-provider-invalidation-flow - scopes: [openid, email, profile] authentik_groups: - name: admins - name: users - - name: opnform-admins - - name: homarr-admins - - name: bookstack-admins authentik_local_users: - username: akadmin diff --git a/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml b/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml index 562f979..c02b660 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/authentik_outpost_ldap.yml @@ -3,5 +3,5 @@ # authenticate against the authentik server it talks to. _authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" -authentik_outpost_ldap_host: "https://auth.int.gymb.souveredu.ch" +authentik_outpost_ldap_host: "https://auth.gymb.souveredu.ch" authentik_outpost_ldap_token: "{{ _authentik.ldap_outpost_token }}" diff --git a/inventories/demo-gymburgdorf/host_vars/application/bookstack.yml b/inventories/demo-gymburgdorf/host_vars/application/bookstack.yml deleted file mode 100644 index 1d0beac..0000000 --- a/inventories/demo-gymburgdorf/host_vars/application/bookstack.yml +++ /dev/null @@ -1,43 +0,0 @@ ---- -# Bao secret /data/bookstack expected to contain: -# db_root_password, db_password, admin_password, oidc_client_secret, -# app_key (optional — only set when restoring) -_bookstack: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/bookstack', url=vault_addr) }}" - -bookstack_domain: "wiki.gymb.souveredu.ch" -bookstack_extra_domains: - - "wiki.int.gymb.souveredu.ch" -bookstack_base_url: "https://wiki.gymb.souveredu.ch" - -# Override the role-default certresolver ("le") with the value used -# across this demo (matches traefik_ssl_cert_resolver in group_vars). -bookstack_traefik_certresolver: "dns" - -bookstack_db_root_password: "{{ _bookstack.db_root_password }}" -bookstack_db_password: "{{ _bookstack.db_password }}" -bookstack_admin_password: "{{ _bookstack.admin_password }}" -bookstack_admin_email: "admin@gymb.souveredu.ch" -bookstack_admin_name: "BookStack Admin" - -# OIDC against Authentik. BookStack compares OIDC_ISSUER strictly against -# the `iss` claim in the discovery response. Authentik emits the public -# auth.gymb.* hostname there (host-rewrite middleware ensures the claim -# matches what browsers see during login), so the issuer URL must use the -# public FQDN. Pinning auth.gymb.* in /etc/hosts below keeps the actual -# server-to-server traffic on the LAN. -bookstack_oidc_enabled: true -bookstack_oidc_name: "Authentik" -bookstack_oidc_issuer: "https://auth.gymb.souveredu.ch/application/o/bookstack/" -bookstack_oidc_client_id: "bookstack" -bookstack_oidc_client_secret: "{{ _bookstack.oidc_client_secret }}" -bookstack_oidc_additional_scopes: "openid profile email" -bookstack_oidc_user_to_groups: true -bookstack_oidc_groups_claim: "groups" -bookstack_oidc_auto_initiate: false - -# Pin auth.gymb.* to the application host so server-to-server OIDC calls -# (discovery, token, userinfo, jwks) stay in the LAN and reach authentik -# directly without hairpinning through the DMZ (which has no NAT loop -# back to its own public IP). -bookstack_extra_hosts: - - "auth.gymb.souveredu.ch:172.16.19.101" diff --git a/inventories/demo-gymburgdorf/host_vars/application/collabora.yml b/inventories/demo-gymburgdorf/host_vars/application/collabora.yml index da251df..b02760c 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/collabora.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/collabora.yml @@ -1,16 +1,8 @@ --- -# 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" +collabora_domain: "office.gymb.souveredu.ch" -# Hosts allowed to issue WOPI calls. Both names are listed so collabora -# accepts the callback from nextcloud regardless of which FQDN it uses. collabora_allowed_domains: - "cloud.gymb.souveredu.ch" - - "cloud.int.gymb.souveredu.ch" collabora_frame_ancestors: - "cloud.gymb.souveredu.ch" diff --git a/inventories/demo-gymburgdorf/host_vars/application/drawio.yml b/inventories/demo-gymburgdorf/host_vars/application/drawio.yml index 2d7f6b6..beef6a5 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/drawio.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/drawio.yml @@ -1,19 +1,2 @@ --- drawio_domain: "draw.gymb.souveredu.ch" - -# Internal FQDN the DMZ reverseproxy uses as backend host so its TLS -# verify matches a cert SAN (the canonical IP-only route has no SAN -# and breaks with "cannot validate certificate ... no IP SANs"). Same -# split-horizon pattern as cloud.int.* / auth.int.* / office.int.*. -drawio_extra_domains: - - "draw.int.gymb.souveredu.ch" - -# Gate drawio behind the authentik embedded outpost (admins-only — -# enforced by the policy-binding on the authentik proxy application). -# ForwardAuth talks to the embedded outpost on the authentik server's -# in-network address. Going via the public FQDN routes through a second -# traefik hop that strips/rewrites X-Forwarded-Host, which breaks -# authentik's provider matching (it returns 404). Plain HTTP to the -# container is the path docs recommend for the embedded outpost. -drawio_authentik_forward_auth: true -drawio_authentik_forward_auth_url: "http://authentik-server-1:9000/outpost.goauthentik.io/auth/traefik" diff --git a/inventories/demo-gymburgdorf/host_vars/application/homarr.yml b/inventories/demo-gymburgdorf/host_vars/application/homarr.yml deleted file mode 100644 index dc3ba2e..0000000 --- a/inventories/demo-gymburgdorf/host_vars/application/homarr.yml +++ /dev/null @@ -1,71 +0,0 @@ ---- -# Bao secret /data/homarr expected to contain: -# secret_encryption_key (64 hex chars), admin_password, oidc_client_secret -_homarr: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/homarr', url=vault_addr) }}" - -homarr_domain: "home.gymb.souveredu.ch" -homarr_extra_domains: - - "home.int.gymb.souveredu.ch" -homarr_base_url: "https://home.gymb.souveredu.ch" - -homarr_secret_encryption_key: "{{ _homarr.secret_encryption_key }}" -homarr_admin_username: "admin" -homarr_admin_email: "admin@gymb.souveredu.ch" -homarr_admin_password: "{{ _homarr.admin_password }}" - -# OIDC against Authentik. credentials provider stays enabled as a -# break-glass account. -homarr_auth_providers: "credentials,oidc" -homarr_oidc_issuer: "https://auth.int.gymb.souveredu.ch/application/o/homarr/" -homarr_oidc_client_id: "homarr" -homarr_oidc_client_secret: "{{ _homarr.oidc_client_secret }}" -homarr_oidc_client_name: "Authentik" -homarr_oidc_scopes: "openid profile email groups" -homarr_oidc_groups_attribute: "groups" -homarr_oidc_admin_group: "homarr-admins" -homarr_oidc_auto_login: "false" - -# Default board with shortcuts to the other gymburgdorf services. Width -# values describe horizontal grid cells (1-10 desktop / 6 tablet / 2 -# mobile, packed left-to-right). -homarr_apps: - - id: nextcloud - name: Nextcloud - description: "Cloud Storage & Collaboration" - icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nextcloud.png - href: https://cloud.gymb.souveredu.ch - width: 2 - - id: collabora - name: Collabora Office - icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/collaboraonline.png - href: https://office.gymb.souveredu.ch - width: 2 - - id: drawio - name: Draw.io - icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/drawio.png - href: https://draw.gymb.souveredu.ch - width: 2 - - id: send - name: Send - description: "Encrypted file-share" - icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/firefox-send.png - href: https://send.gymb.souveredu.ch - width: 2 - - id: opnform - name: OpnForm - description: "Self-hosted forms" - icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/opnform.png - href: https://forms.gymb.souveredu.ch - width: 2 - - id: bookstack - name: BookStack - description: "Wiki & documentation" - icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/bookstack.png - href: https://wiki.gymb.souveredu.ch - width: 2 - - id: authentik - name: Authentik - description: "Identity provider" - icon: https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/authentik.png - href: https://auth.gymb.souveredu.ch - width: 2 diff --git a/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml b/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml index b30a0f9..a1719f4 100644 --- a/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml +++ b/inventories/demo-gymburgdorf/host_vars/application/nextcloud.yml @@ -4,36 +4,16 @@ _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) }}" -# 33.0.2 hits the PHP 8.4 TypeError in UserConfig::getValueBool() that -# user_ldap triggers on every authenticated request (nextcloud/server -# #59629; fix in 33.0.3). Pin to the patched tag. -nextcloud_image: "nextcloud:33.0.3-fpm" - -# 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_domain: "cloud.gymb.souveredu.ch" nextcloud_postgres_password: "{{ _nextcloud.postgres_password }}" nextcloud_admin_user: admin nextcloud_admin_password: "{{ _nextcloud.admin_password }}" nextcloud_enable_notify_push: true -# Use the internal FQDN for the notify_push setup check so curl from the -# nextcloud container hits the local traefik directly instead of -# hairpinning through the DMZ reverseproxy. -nextcloud_notify_push_domain: "cloud.int.gymb.souveredu.ch" # Collabora integration -# wopi_url (server-to-server: nextcloud calls collabora for discovery / -# capabilities) goes to the internal FQDN so the call stays in the LAN. -# public_wopi_url is what the browser loads the office iframe from — that -# stays on the public name reachable through the DMZ. nextcloud_enable_collabora: true -nextcloud_collabora_domain: "office.int.gymb.souveredu.ch" -nextcloud_collabora_public_domain: "office.gymb.souveredu.ch" +nextcloud_collabora_domain: "office.gymb.souveredu.ch" # Draw.io integration nextcloud_enable_drawio: true @@ -50,37 +30,20 @@ nextcloud_apps_to_install: - files_lock - notify_push -# 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. +# S3 primary storage via Garage nextcloud_use_s3_storage: true nextcloud_s3_key: "{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='storage')['key_id'] }}" nextcloud_s3_secret: "{{ lookup('digitalboard.core.garage_credentials', 'nextcloud', host='storage')['secret_key'] }}" nextcloud_s3_bucket: "nextcloud" -nextcloud_s3_host: "s3.int.gymb.souveredu.ch" +nextcloud_s3_host: "{{ hostvars['storage']['garage_s3_domain'] }}" nextcloud_s3_port: 443 nextcloud_s3_ssl: true nextcloud_s3_usepath_style: true -# OIDC server-to-server discovery / token / userinfo goes to -# auth.int.gymb.souveredu.ch (LAN, RFC1918). Nextcloud's DnsPinMiddleware -# would otherwise block that as "local server access". -nextcloud_allow_local_remote_servers: true - # Share the LDAP docker network with the authentik LDAP outpost nextcloud_extra_networks: - ldap -# Pin the public authentik FQDN to the application host so server-to-server -# OIDC traffic (token, userinfo, jwks — endpoints the discovery doc lists -# under auth.gymb.* even when discovery itself is fetched via auth.int.*) -# stays in the LAN. Without this, curl in the PHP container would hit the -# public IP and time out in the DMZ (no hairpin-NAT). The DnsPin middleware -# only honours /etc/hosts when allow_local_remote_servers is enabled, so -# that flag (set above) is what makes this entry effective. -nextcloud_extra_hosts: - - "auth.gymb.souveredu.ch:172.16.19.101" - # LDAP backend (Authentik LDAP outpost) nextcloud_ldap_enabled: true nextcloud_ldap_config: @@ -118,13 +81,7 @@ nextcloud_oidc_providers: display_name: "Login with Authentik" client_id: nextcloud client_secret: "{{ _authentik.nextcloud_oidc_secret }}" - # Discovery via the internal FQDN (LAN-only) — the DMZ has no - # hairpin-NAT for the public IP, so server-to-server calls to - # auth.gymb.* would time out. The traefik router for auth.int.* - # rewrites the Host header to auth.gymb.souveredu.ch before the - # request reaches authentik, so the iss claim authentik emits still - # matches the public hostname the browser sees during login. - discovery_url: "https://auth.int.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" unique_uid: true mapping: diff --git a/inventories/demo-gymburgdorf/host_vars/application/opnform.yml b/inventories/demo-gymburgdorf/host_vars/application/opnform.yml deleted file mode 100644 index 1526ac0..0000000 --- a/inventories/demo-gymburgdorf/host_vars/application/opnform.yml +++ /dev/null @@ -1,60 +0,0 @@ ---- -# Bao secret /data/opnform expected to contain: -# app_key (must start with "base64:"), jwt_secret, front_api_secret, -# db_password, admin_password, oidc_client_secret -_opnform: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/opnform', url=vault_addr) }}" -_authentik: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/authentik', url=vault_addr) }}" - -opnform_domain: "forms.gymb.souveredu.ch" -opnform_extra_domains: - - "forms.int.gymb.souveredu.ch" -opnform_base_url: "https://forms.gymb.souveredu.ch" - -opnform_app_key: "{{ _opnform.app_key }}" -opnform_jwt_secret: "{{ _opnform.jwt_secret }}" -opnform_front_api_secret: "{{ _opnform.front_api_secret }}" -opnform_db_password: "{{ _opnform.db_password }}" - -# Bootstrap admin via API on first run so the manual setup page is -# skipped. The admin credentials are also required to seed the OIDC -# IdentityConnection through OpnForm's API (only an authenticated admin -# can create connections). -opnform_admin_name: "OpnForm Admin" -opnform_admin_email: "admin@gymb.souveredu.ch" -opnform_admin_password: "{{ _opnform.admin_password }}" - -# OIDC against Authentik. Discovery via the internal FQDN keeps -# server-to-server traffic in the LAN; Authentik's host-rewrite router -# rewrites the Host header to auth.gymb.* before the request reaches -# authentik so the iss claim still matches the public hostname browsers -# see during login. -opnform_oidc_enabled: true -# Issuer must use the public auth.gymb.* FQDN: OpnForm does OIDC -# discovery and then validates the token's `iss` claim against this -# value. Authentik emits the public hostname in `iss` (its host-rewrite -# middleware keeps the claim aligned with what browsers see), so an -# internal-FQDN issuer here would fail iss validation. The extra_hosts -# pin below keeps the actual discovery/token/userinfo traffic on the LAN. -opnform_oidc_issuer: "https://auth.gymb.souveredu.ch/application/o/opnform/" -opnform_oidc_client_id: "opnform" -opnform_oidc_client_secret: "{{ _opnform.oidc_client_secret }}" -opnform_oidc_client_name: "Authentik" -opnform_oidc_slug: "authentik" -opnform_oidc_domain: "gymb.souveredu.ch" -opnform_oidc_admin_group: "opnform-admins" - -# Disable password login entirely — every user goes through Authentik. -# All real users have @gymb.souveredu.ch addresses (matching -# opnform_oidc_domain above), so no password fallback is needed. -opnform_oidc_force_login: true - -# Serve a /sso page that jumps straight to Authentik without the email -# login form. Link users to https://forms.gymb.souveredu.ch/sso. -opnform_oidc_sso_entrypoint: true - -# Pin auth.gymb.* to the application host so server-to-server OIDC -# calls (token, userinfo, jwks — endpoints discovery returns under the -# public hostname even when discovery itself is fetched via auth.int.*) -# stay in the LAN. -opnform_extra_hosts: - - "auth.gymb.souveredu.ch:172.16.19.101" diff --git a/inventories/demo-gymburgdorf/host_vars/application/send.yml b/inventories/demo-gymburgdorf/host_vars/application/send.yml deleted file mode 100644 index ee1887a..0000000 --- a/inventories/demo-gymburgdorf/host_vars/application/send.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -# Send: anonymized self-hosted file-share (no login). First entry is the -# canonical public FQDN (used as BASE_URL); the *.int.* entry covers the -# server-to-server hop from the DMZ reverseproxy with a cert SAN that -# matches the backend hostname (same split-horizon pattern as cloud/draw). -send_domains: - - "send.gymb.souveredu.ch" - - "send.int.gymb.souveredu.ch" diff --git a/inventories/demo-gymburgdorf/host_vars/application/traefik.yml b/inventories/demo-gymburgdorf/host_vars/application/traefik.yml deleted file mode 100644 index a9db420..0000000 --- a/inventories/demo-gymburgdorf/host_vars/application/traefik.yml +++ /dev/null @@ -1,46 +0,0 @@ ---- -# 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 - backend_host: draw.int.gymb.souveredu.ch - port: 443 - protocol: https - - name: send - domain: send.gymb.souveredu.ch - backend_host: send.int.gymb.souveredu.ch - port: 443 - protocol: https - - name: opnform - domain: forms.gymb.souveredu.ch - backend_host: forms.int.gymb.souveredu.ch - port: 443 - protocol: https - - name: homarr - domain: home.gymb.souveredu.ch - backend_host: home.int.gymb.souveredu.ch - port: 443 - protocol: https - - name: bookstack - domain: wiki.gymb.souveredu.ch - backend_host: wiki.int.gymb.souveredu.ch - port: 443 - protocol: https diff --git a/inventories/demo-gymburgdorf/host_vars/reverseproxy/traefik.yml b/inventories/demo-gymburgdorf/host_vars/reverseproxy/traefik.yml index 48f6d08..c43e6be 100644 --- a/inventories/demo-gymburgdorf/host_vars/reverseproxy/traefik.yml +++ b/inventories/demo-gymburgdorf/host_vars/reverseproxy/traefik.yml @@ -1,22 +1,28 @@ --- traefik_mode: dmz -# The DMZ traefik discovers which services to expose by reading -# traefik_dmz_exposed_services from each backend host's host_vars -# (application/traefik.yml, storage/traefik.yml). See the role's -# tasks/main.yml — set_fact "Build service registry from backend -# servers (DMZ mode)". - -# From the DMZ network the public ns1 IP (193.43.183.169) is not -# reachable on port 53, but the internal address (172.16.9.169) is. -# Override the group-level traefik_acme_dns_nameserver from bao so -# lego's RFC2136 updates land at the internal interface. The TSIG -# key/secret are the same; only the transport target changes. -traefik_acme_dns_nameserver: "172.16.9.169" - -# Lego's propagation check normally polls the NS hostnames listed in -# the zone's SOA (ns1.digitalboard.ch.) — which resolves to the -# public IP that's unreachable from this DMZ host. Skip that check; -# lego still polls via the resolver above before asking LE to -# validate. -traefik_acme_disable_ans_checks: true +traefik_dmz_exposed_services: + - name: authentik + domain: auth.gymb.souveredu.ch + port: 443 + protocol: https + - name: nextcloud + domain: cloud.gymb.souveredu.ch + port: 443 + protocol: https + - name: collabora + domain: office.gymb.souveredu.ch + port: 443 + protocol: https + - name: drawio + domain: draw.gymb.souveredu.ch + port: 443 + protocol: https + - name: garage-webui + domain: console.s3.gymb.souveredu.ch + port: 443 + protocol: https + - name: garage-s3 + domain: s3.gymb.souveredu.ch + port: 443 + protocol: https diff --git a/inventories/demo-gymburgdorf/host_vars/storage/garage.yml b/inventories/demo-gymburgdorf/host_vars/storage/garage.yml index 37f4179..bc392c9 100644 --- a/inventories/demo-gymburgdorf/host_vars/storage/garage.yml +++ b/inventories/demo-gymburgdorf/host_vars/storage/garage.yml @@ -3,27 +3,12 @@ # rpc_secret, admin_token, metrics_token, webui_password _garage: "{{ lookup('community.hashi_vault.hashi_vault', vault_mount + '/data/garage', url=vault_addr) }}" -# 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_s3_domain: "s3.gymb.souveredu.ch" garage_webui_domain: "console.s3.gymb.souveredu.ch" garage_use_ssl: true garage_webui_enabled: true -# Gate the WebUI behind authentik (admins-only, via policy-binding on the -# authentik proxy app). Replaces the htpasswd Basic-Auth — AUTH_USER_PASS -# is dropped from the compose env when this is true. The forwardauth URL -# resolves to the application-host traefik (network alias -# `auth.gymb.souveredu.ch` -> authentik-server-1 in the proxy network on -# the application host), but THIS host (storage) is in a different LAN, -# so traefik here reaches it via the public name through the DMZ proxy. -garage_webui_authentik_forward_auth: true -garage_webui_authentik_forward_auth_url: "https://auth.gymb.souveredu.ch/outpost.goauthentik.io/auth/traefik" -# Kept for completeness — only used when authentik ForwardAuth is off. garage_webui_username: "admin" -garage_webui_password: "{{ _garage.webui_password | default('disabled') }}" +garage_webui_password: "{{ _garage.webui_password }}" garage_rpc_secret: "{{ _garage.rpc_secret }}" garage_admin_token: "{{ _garage.admin_token }}" diff --git a/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml b/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml deleted file mode 100644 index e704bc3..0000000 --- a/inventories/demo-gymburgdorf/host_vars/storage/traefik.yml +++ /dev/null @@ -1,24 +0,0 @@ ---- -# Local traefik needs to reach authentik for the ForwardAuth subrequest -# the garage-webui router fires. The public IP is unreachable from this -# subnet (no DMZ hairpin), so point auth.gymb.* directly at the -# application host where authentik runs. Without this the forwardauth -# middleware would time out and every garage-console request would 502. -traefik_extra_hosts: - - "auth.gymb.souveredu.ch:172.16.19.101" - -# Services hosted on `storage` that the DMZ reverseproxy should forward -# public traffic to. See application/traefik.yml for the mechanism. -traefik_dmz_exposed_services: - - name: garage-s3 - domain: s3.gymb.souveredu.ch - backend_host: s3.int.gymb.souveredu.ch - port: 443 - protocol: https - - name: garage-webui - domain: console.s3.gymb.souveredu.ch - # No internal FQDN/cert SAN for console.s3 yet — would need an - # extra_domain on garage-webui. Until then this route will 500 - # against the storage backend (cert mismatch on raw IP). - port: 443 - protocol: https diff --git a/inventories/demo-gymburgdorf/hosts.yml b/inventories/demo-gymburgdorf/hosts.yml index 66261bc..220784b 100644 --- a/inventories/demo-gymburgdorf/hosts.yml +++ b/inventories/demo-gymburgdorf/hosts.yml @@ -45,21 +45,5 @@ all: application: authentik_outpost_ldap_servers: - hosts: - application: - - send_servers: - hosts: - application: - - opnform_servers: - hosts: - application: - - homarr_servers: - hosts: - application: - - bookstack_servers: hosts: application: \ No newline at end of file diff --git a/playbooks/site.yml b/playbooks/site.yml index 8d64821..c783daa 100644 --- a/playbooks/site.yml +++ b/playbooks/site.yml @@ -71,26 +71,14 @@ roles: - digitalboard.core.send -- name: Deploy opnform service - hosts: opnform_servers +- name: Deploy openforms service + hosts: openforms_servers become: yes roles: - - digitalboard.core.opnform - -- name: Deploy homarr service - hosts: homarr_servers - become: yes - roles: - - digitalboard.core.homarr - -- name: Deploy bookstack service - hosts: bookstack_servers - become: yes - roles: - - digitalboard.core.bookstack + - digitalboard.core.openforms - name: Deploy opencloud service hosts: opencloud_servers become: yes roles: - - digitalboard.core.opencloud + - digitalboard.core.opencloud \ No newline at end of file diff --git a/scripts/bao-seed.sh b/scripts/bao-seed.sh deleted file mode 100755 index 80945b7..0000000 --- a/scripts/bao-seed.sh +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env bash -# -# Seed OpenBao secrets for a demo inventory. Merge semantics: existing -# keys at a given path are kept; only missing keys are generated. OIDC -# client secrets are synced between /data/authentik and the -# per-service secret so authentik and the service agree on the same -# value. -# -# Usage: -# scripts/bao-seed.sh demo-gymburgdorf -# -# Requirements: -# - bao CLI in $PATH, authenticated (make bao) -# - jq in $PATH -# - openssl in $PATH -# -# Environment overrides: -# BAO_ADDR default https://bao.digitalboard.ch -# DRY_RUN=1 print what would change without writing - -set -eu - -MOUNT="${1:-}" -if [[ -z "$MOUNT" ]]; then - echo "usage: $0 (e.g. demo-gymburgdorf)" >&2 - exit 1 -fi - -: "${BAO_ADDR:=https://bao.digitalboard.ch}" -export BAO_ADDR - -for cmd in bao jq openssl mktemp; do - command -v "$cmd" >/dev/null || { echo "missing: $cmd" >&2; exit 1; } -done - -if ! bao token lookup >/dev/null 2>&1; then - echo "not authenticated — run 'make bao' first" >&2 - exit 1 -fi - -DRY_RUN="${DRY_RUN:-0}" - -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT - -# Read current data for a KV-v2 secret into $WORKDIR/.json. -# Writes {} if the secret does not exist or has no data yet. -read_secret() { - local path="$1" - local out="$WORKDIR/$path.json" - local raw="$WORKDIR/$path.raw" - - if bao kv get -format=json "$MOUNT/$path" >"$raw" 2>/dev/null; then - if jq -e -c '.data.data // {}' "$raw" >"$out" 2>/dev/null; then - return 0 - fi - fi - echo '{}' >"$out" -} - -# Write the in-memory secret back to bao using `bao kv put @file` so -# values containing `=` (e.g. base64 padding) survive intact. -write_secret() { - local path="$1" - local src="$WORKDIR/$path.json" - - if [[ "$DRY_RUN" == "1" ]]; then - echo " [dry-run] would write $MOUNT/$path" - return - fi - bao kv put "$MOUNT/$path" "@$src" >/dev/null -} - -# Print the current value of a key from a secret file, empty string if -# absent. -get_key() { - local file="$1" - local key="$2" - jq -r --arg k "$key" '.[$k] // ""' "$file" -} - -# Set a key inside the secret file to the given literal value. -set_key() { - local file="$1" - local key="$2" - local value="$3" - local tmp="$file.tmp" - jq --arg k "$key" --arg v "$value" '.[$k] = $v' "$file" >"$tmp" - mv "$tmp" "$file" -} - -# Generate a key in the secret file if missing. -ensure_key() { - local path="$1" - local key="$2" - local generator="$3" - local file="$WORKDIR/$path.json" - - if [[ -n "$(get_key "$file" "$key")" ]]; then - return - fi - local value - value="$($generator)" - set_key "$file" "$key" "$value" - echo " + $key" -} - -# Force a key on path2 to match path1's value for the named key. -# Prints when a change happens. -sync_key_from() { - local src_path="$1" - local src_key="$2" - local dst_path="$3" - local dst_key="$4" - - local src_file="$WORKDIR/$src_path.json" - local dst_file="$WORKDIR/$dst_path.json" - - local src_val - src_val="$(get_key "$src_file" "$src_key")" - if [[ -z "$src_val" ]]; then - echo " ! $src_path/$src_key missing — cannot sync to $dst_path/$dst_key" >&2 - return - fi - - local dst_val - dst_val="$(get_key "$dst_file" "$dst_key")" - if [[ "$src_val" == "$dst_val" ]]; then - return - fi - set_key "$dst_file" "$dst_key" "$src_val" - echo " = $dst_key (synced from $src_path/$src_key)" -} - -gen_hex32() { openssl rand -hex 32; } -gen_hex64() { openssl rand -hex 64; } -gen_pass() { openssl rand -base64 24 | tr -d '/+='; } -gen_long_pass() { openssl rand -base64 32 | tr -d '/+='; } -gen_app_key() { echo "base64:$(openssl rand -base64 32)"; } - -# OpnForm requires: min 8 chars, a letter, a digit, AND one of the -# special chars @$!%*#?&-_+=.,:;<>^()[]{}|~. The base64 generator alone -# only produces [A-Za-z0-9], so we append a fixed-position special char -# and digit to guarantee the rule passes regardless of entropy outcome. -gen_opnform_pass() { echo "$(openssl rand -base64 18 | tr -d '/+=')!1Aa"; } - -# ---------------------------------------------------------------- main - -echo "==> seeding $MOUNT (dry-run=$DRY_RUN)" - -echo "-> authentik" -read_secret authentik -ensure_key authentik secret_key gen_hex64 -ensure_key authentik postgres_password gen_pass -ensure_key authentik admin_password gen_pass -ensure_key authentik ldap_outpost_token gen_hex32 -ensure_key authentik nextcloud_oidc_secret gen_hex32 -ensure_key authentik opnform_oidc_secret gen_hex32 -ensure_key authentik homarr_oidc_secret gen_hex32 -ensure_key authentik bookstack_oidc_secret gen_hex32 -write_secret authentik - -echo "-> opnform" -read_secret opnform -ensure_key opnform app_key gen_app_key -ensure_key opnform jwt_secret gen_hex32 -ensure_key opnform front_api_secret gen_hex32 -ensure_key opnform db_password gen_long_pass -ensure_key opnform admin_password gen_opnform_pass -ensure_key opnform oidc_client_secret gen_hex32 -sync_key_from authentik opnform_oidc_secret opnform oidc_client_secret -write_secret opnform - -echo "-> homarr" -read_secret homarr -ensure_key homarr secret_encryption_key gen_hex32 -ensure_key homarr admin_password gen_pass -ensure_key homarr oidc_client_secret gen_hex32 -sync_key_from authentik homarr_oidc_secret homarr oidc_client_secret -write_secret homarr - -echo "-> bookstack" -read_secret bookstack -ensure_key bookstack db_root_password gen_long_pass -ensure_key bookstack db_password gen_long_pass -ensure_key bookstack admin_password gen_pass -ensure_key bookstack oidc_client_secret gen_hex32 -sync_key_from authentik bookstack_oidc_secret bookstack oidc_client_secret -write_secret bookstack - -echo "==> done"