CT-102 Keycloak: Migration nach Podman und Ansible abgeschlossen und gehärtet.
This commit is contained in:
@@ -2,7 +2,11 @@
|
||||
|
||||
Dokumentation für die Infrastruktur eines privaten Homelabs mit öffentlich erreichbaren Webdiensten, einem WireGuard-Zugang und einem dedizierten KI-Server. Dieses Repository hält Architekturentscheidungen, den belegten Betriebsstand und Verfahren für Einrichtung, Betrieb und Sicherheitsabnahme fest.
|
||||
|
||||
> **Projektstand: 21.09.2026.** Die Dokumentationsrevisionen sind zusammengeführt. Mehrere Sicherheitsmaßnahmen und technische Endabnahmen stehen noch aus. Auf dem KI-Server sind Host-Baseline, Podman, getrennte rootless Workload-Konten und der Gerätezugriff der KI-Konten auf beide AMD-GPUs eingerichtet. Ein gepinnter `llama.cpp`-ROCm-Build erkennt beide Karten im rootless Testing-Container; Swift- und Unsloth-Q6_K sind als 128k-A/B-Kandidaten geplant, aber dauerhafte LLM- und Encoding-Anwendungen sind noch nicht installiert.
|
||||
> **Projektstand: 14.10.2026.** Pi-hole/Split-DNS, WireGuard-Rollenpolicy und
|
||||
> der per Ansible reproduzierbare Keycloak-/PostgreSQL-Stack sind produktiv
|
||||
> abgenommen. Der öffentliche Keycloak-Adminpfad ist gesperrt, der interne
|
||||
> HTTPS-Adminpfad funktioniert und der reale Vaultwarden-OIDC-Login wurde
|
||||
> bestätigt. Dauerhaftes LLM-Serving und Encoding sind noch nicht installiert.
|
||||
|
||||
## Überblick
|
||||
|
||||
@@ -20,16 +24,16 @@ flowchart LR
|
||||
|
||||
Der Reverse Proxy und das VPN-Gateway laufen in getrennten Proxmox-LXCs. Weitere Webdienste befinden sich in eigenen LXCs. Der physische KI-Server ist für lokale Inferenz und darauf aufbauende Anwendungen vorgesehen. Die gestrichelte Verbindung zum KI-Server beschreibt einen **geplanten** Veröffentlichungsweg für LLM-Anwendungen; die Dienste sind dort noch nicht installiert.
|
||||
|
||||
Die drei vorgesehenen VPN-Netzwerkrollen sind **Familie** (gesamtes Heimnetz), **Server Admin** (bestätigte Server und Service-LXCs) und **LLM Admin** (nur KI-Server). Ihre serverseitige Durchsetzung ist noch nicht abgenommen. Anwendungsanmeldung, SSH-Schlüssel und API-Schlüssel bleiben zusätzliche, eigenständige Zugangskontrollen.
|
||||
Die drei vorgesehenen VPN-Netzwerkrollen sind **Familie** (gesamtes Heimnetz), **Server Admin** (bestätigte Server und Service-LXCs) und **LLM Admin** (nur KI-Server). Der derzeit einzige Peer `ghost-laptop` ist als Server Admin durch native nftables-Regeln abgenommen. Anwendungsanmeldung, SSH-Schlüssel und API-Schlüssel bleiben zusätzliche, eigenständige Zugangskontrollen.
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
| Bereich | Belegter Stand | Nächster Nachweis oder Schritt |
|
||||
| --- | --- | --- |
|
||||
| KI-Server | Debian-Basissystem, Deployment-Zugang, Podman 5.4.2, drei getrennte rootless Workload-Konten sowie zwei als `gfx1030` und als `llama.cpp`-ROCm-Geräte geprüfte RX 6900 XT | nach der Prioritätsentscheidung internes `llama.cpp`-Testing ohne öffentliche Route bereitstellen |
|
||||
| VPN | wg-easy v15.4.0 auf CT 105 aktiv; ein externer Laptop-Teiltest liegt vor | Rollenrechte serverseitig umsetzen und mit erlaubten sowie gesperrten Zielen testen |
|
||||
| VPN | wg-easy v15.4.0, native Rollenpolicy/NAT und externer Positiv-/Negativtest abgenommen | WAN-/IPv6-Exposure, Restore und GUI-HTTPS prüfen |
|
||||
| Reverse Proxy | NPM auf CT 103 dokumentiert; Fail2Ban laut Betreiber fest gepinnt und wieder in Betrieb | Effektive Jails, Sperren und Wiederanlauf prüfen |
|
||||
| Identität | Keycloak/OIDC laut Betreiber produktiv für Vaultwarden | Öffentlich erreichbare Keycloak-Administration sperren und den OIDC-Login erneut prüfen |
|
||||
| Identität | CT 102 per Ansible mit Podman/PostgreSQL reproduziert; interner Adminpfad, öffentliche Sperre, Reboot und realer Vaultwarden-OIDC-Login abgenommen | Stabilisierungsphase und praktischen CT-104-Rückfalltest abschließen |
|
||||
|
||||
Der [Dokumentationsindex](docs/GNAI-000-DOC_Index.md) enthält die datierten Quellen, offenen Angaben und Abnahmegrenzen. Er ist die maßgebliche Übersicht für den aktuellen Projektstand. Eine Zielbeschreibung in diesem README oder den Architekturdokumenten ist kein Nachweis für eine bereits wirksame Konfiguration.
|
||||
|
||||
@@ -40,18 +44,23 @@ Der [Dokumentationsindex](docs/GNAI-000-DOC_Index.md) enthält die datierten Que
|
||||
| [Dokumentationsindex und Projektstand](docs/GNAI-000-DOC_Index.md) | Dokumentenübersicht, bestätigte Entscheidungen und offene Befunde |
|
||||
| [High-Level-Implementierungsplan](docs/GNAI-001-PLAN_Project-Roadmap.md) | priorisierte nächste Schritte, Entscheidungstore und messbare Abschlusskriterien |
|
||||
| [Plattform-Dokumentation](docs/platform/README.md) | Hostbasis, Zugriffsmodell, Reverse Proxy und VPN |
|
||||
| [KI-Dokumentation](docs/ai/README.md) | KI-Zielarchitektur, Ansible-Planung, Modellbewertung und technische `llama.cpp`-Sollkonfiguration |
|
||||
| [Video-Encoding](docs/video-encoding/GNENC-000-DOC_Index.md) | getrennter Planungs- und späterer SOP-Bereich für den CPU-Encoding-Workload |
|
||||
| [Ansible-Control-Projekt](ansible/README.md) | lokale Einrichtung, Preflight, Deployment-Account, Host-Baseline, Podman, Workload-Konten, AMD-GPU-Zugriff und rootless ROCm-Probe |
|
||||
| [KI-Dokumentation](docs/platform/llm-server/ai/README.md) | KI-Zielarchitektur, Ansible-Planung, Modellbewertung und technische `llama.cpp`-Sollkonfiguration |
|
||||
| [Video-Encoding](docs/platform/llm-server/encoding/GNENC-000-DOC_Index.md) | getrennter Planungs- und späterer SOP-Bereich für den CPU-Encoding-Workload |
|
||||
| [LLM-Server-Automation](llm-server/ansible/README.md) | Host-Baseline, Podman, Workload-Konten, AMD-GPU-Zugriff und rootless ROCm-Probe |
|
||||
| [Keycloak-CT-102-Automation](proxmox/lxc/ct-102-keycloak/ansible/README.md) | Inventare, Rollen, Restore- und Produktionsverifikation für CT 102 |
|
||||
|
||||
## Arbeiten mit dem Repository
|
||||
|
||||
Dieses Repository enthält Dokumentation und ein Ansible-Control-Projekt. Es prüft Inventar, SSH-Zugang, Hostidentität, Betriebssystem und die dokumentierten GPUs. Gemeinsame Rollen verwalten Plattform, Podman und getrennte Workload-Identitäten. KI- und Encoding-Fachdokumentation bleiben in getrennten Bereichen. Im Testing-Store liegt nur das gepinnte ROCm-Probeimage; dauerhafte Anwendungscontainer sind noch nicht bereitgestellt. Vor einer Infrastrukturänderung den Ist-Stand am betroffenen Host prüfen, die passende Fachanleitung lesen und die dort genannten Abnahmeschritte dokumentieren.
|
||||
Dieses Repository enthält Dokumentation und hostbezogene Ansible-Projekte.
|
||||
Der physische KI-Server liegt unter `llm-server/`, Proxmox-Gäste unter
|
||||
`proxmox/lxc/<ct-id>-<dienst>/`; nur tatsächlich wiederverwendbare Rollen und
|
||||
Control-Node-Pins liegen unter `shared/ansible/`. Vor einer
|
||||
Infrastrukturänderung den Ist-Stand am betroffenen Host prüfen, die passende
|
||||
Fachanleitung lesen und die dort genannten Abnahmeschritte dokumentieren.
|
||||
|
||||
WireGuard-Profile enthalten private Schlüssel. Sie gehören nicht in Git,
|
||||
Ausgaben oder freigegebene Dokumente. Das lokale Verzeichnis
|
||||
`docs/platform/GNAI-007-profiles/` wird vollständig durch die
|
||||
Repository-`.gitignore` ausgeschlossen; es ist kein Verteiler- oder Backup-Ort
|
||||
für Zugangsdaten.
|
||||
Ausgaben oder freigegebene Dokumente. Die geschützte lokale Ablage liegt
|
||||
außerhalb des Repositorys; eine Ignore-Regel ist kein Ersatz für sichere
|
||||
Aufbewahrung.
|
||||
|
||||
Änderungen am Projektstand sollten zuerst im zuständigen Fachdokument mit Datum und Beleg erfasst werden. Anschließend den [Index](docs/GNAI-000-DOC_Index.md) und diese README aktualisieren, wenn sich eine übergreifende Aussage geändert hat.
|
||||
|
||||
+20
-10
@@ -1,6 +1,6 @@
|
||||
# Ghostnet.AI – Dokumentationsindex und Projektstand
|
||||
|
||||
**Stand:** 2026-10-05
|
||||
**Stand:** 2026-10-14
|
||||
|
||||
**Status:** Plattformgrundlage vorhanden; Sicherheitsabschluss und LLM-Serving offen
|
||||
|
||||
@@ -30,6 +30,9 @@ jeweiligen Fachdokumenten. Die priorisierte weitere Umsetzung beschreibt der
|
||||
| [Proxmox-Server](platform/proxmox-server/README.md) | Geräteindex für den Hypervisor-Kontext und seine LXCs |
|
||||
| [Proxmox-LXC-SSH](platform/proxmox-server/GNAI-011-SEC_LXC-SSH-Architecture.md) | GNAI-011 gemeinsame SSH-Sicherheitsarchitektur der LXCs; Hypervisor ausgenommen |
|
||||
| [GNAI-012](platform/proxmox-server/lxc/ct-100-dns/GNAI-012-PLAN_Pi-hole-DNS-Handoff.md) | implementierter Pi-hole-LXC, FRITZ!Box-DNS, Split-DNS, Abnahme und Rückfallstand |
|
||||
| [CT 102 / GNAI-014](platform/proxmox-server/lxc/ct-102-keycloak/GNAI-014-SEC_Keycloak.md) | Keycloak-LXC, sichere SSH-Zugänge und Trennung von öffentlichem OIDC und interner Administration |
|
||||
| [GNAI-015](platform/proxmox-server/lxc/ct-102-keycloak/GNAI-015-PLAN_Keycloak-Podman-Migration.md) | Blue/Green-Migration von Keycloak/H2 auf Podman, PostgreSQL und aktuelle 26.x-Version |
|
||||
| [GNAI-016](platform/proxmox-server/lxc/ct-102-keycloak/GNAI-016-PLAN_Keycloak-Ansible-Rebuild.md) | nachgelagerter reproduzierbarer Neuaufbau von CT 102 mit Ansible und zweiter Blue/Green-Cutover von CT 104 |
|
||||
| [CT 103 / GNAI-006](platform/proxmox-server/lxc/ct-103-reverse-proxy/GNAI-006-SEC_Reverse-Proxy.md) | NPM, Fail2Ban und Keycloak-Adminpfad |
|
||||
| [CT 105 / GNAI-007](platform/proxmox-server/lxc/ct-105-wireguard/GNAI-007-SEC_VPN.md) | WireGuard-Gateway und Rollen-Firewall |
|
||||
| [VPN-Client-Verwaltung](platform/proxmox-server/lxc/ct-105-wireguard/GNAI-007-SEC_VPN_Client-Management.md) | Ausgabe, Änderung und Widerruf von Geräteprofilen |
|
||||
@@ -45,7 +48,8 @@ jeweiligen Fachdokumenten. Die priorisierte weitere Umsetzung beschreibt der
|
||||
| [GNAI-008](platform/llm-server/ai/GNAI-008-PLAN_Ansible-Deployment.md) | detaillierter Ansible-/Gitea-Planungsstand und Implementierungshistorie |
|
||||
| [GNAI-009](platform/llm-server/ai/GNAI-009-PLAN_Llama-Model-Evaluation.md) | reproduzierbarer Modellvergleich |
|
||||
| [GNAI-010](platform/llm-server/ai/GNAI-010-TECH_Llama-Serving.md) | technische Sollkonfiguration des Testing-Servings |
|
||||
| [Ansible-Control-Projekt](../ansible/README.md) | ausführbare Playbooks, Rollen und belegter technischer Stand |
|
||||
| [LLM-Server-Ansible](../llm-server/ansible/README.md) | ausführbare Host-/ROCm-Playbooks und Rollen für `10.0.0.90` |
|
||||
| [Keycloak-Ansible](../proxmox/lxc/ct-102-keycloak/ansible/README.md) | ausführbare Provisionierungs-, Restore- und Prüfautomation für CT 102 |
|
||||
|
||||
### Video-Encoding
|
||||
|
||||
@@ -71,9 +75,10 @@ Am 05.10.2026 wurde die Dokumentation geräteorientiert neu geordnet:
|
||||
| GPU/ROCm | beide RX 6900 XT als `gfx1030`, `ROCm0` und `ROCm1` im gepinnten Testing-Image erkannt | Modellladen, Layer-Verteilung, reale Inferenz, 64k/128k-Stabilität |
|
||||
| LLM-Serving | zwei unveränderlich definierte Modellkandidaten und technische Sollkonfiguration | Modelle, Quadlets, dauerhaftes Podman-Netz und Health Checks |
|
||||
| Deployment | lokales Ansible-Control-Projekt und idempotente Hostrollen | statische CI, Runner, geschützte Freigaben, Production-Inventar und automatisierter Rollback |
|
||||
| VPN | wg-easy v15.4.0 und Regelwerke am 22.09.2026 live geprüft; Schlüsseldateien auf `0600` gehärtet | serverseitige Rollenrechte, negative Tests, IPv6-/Restart-/Restore-Abnahme |
|
||||
| VPN | wg-easy v15.4.0; einziger Peer `ghost-laptop` als Server Admin; native nftables-Zielpolicy und NAT, Legacy-Bereinigung, Fremdnetz-, Positiv-/Negativ-, Dienst- und LXC-Wiederanlauftests bestanden; CT 104 am 07.10.2026 als zwölftes Ziel praktisch abgenommen | WAN-/IPv6-Managementport-Isolation, Backup-Restore, wg-easy-GUI-HTTPS und KI-Server-Test bei eingeschaltetem Host |
|
||||
| DNS / Pi-hole | CT 100 unter `10.0.0.14` mit digest-gepinntem Pi-hole 2026.09.0 als Podman-Quadlet; SSH/nftables, UDP/TCP-DNS, Split-DNS, Blocking/Allowlist, FRITZ!Box-Cutover, LXC-Reboot und Stop-Mode-Backup am 01.10.2026 abgenommen | vollständige App-/OIDC-Regression, praktischer FRITZ!Box-Rollback, mobile DuckDuckGo-/IPv6-Strategie, WireGuard-Profile und hostunabhängiger Restore-Test |
|
||||
| Reverse Proxy | CT-103-Lesekonto, persönlicher Schlüsselzugang mit passwortgeschütztem sudo sowie SSH- und LAN/WireGuard-Netzbaseline einschließlich Reboot abgenommen; lokales Stop-Mode-Backup technisch geprüft; NPM 2.15.1 und Fail2Ban aktiv; sensible NPM-Dateirechte gehärtet | wirksame Jails/Bans und deren Wiederanlauf; internen Keycloak-Adminweg bestätigen und öffentlichen Adminpfad sperren; hostunabhängige Backupkopie/Restore-Test |
|
||||
| Reverse Proxy | CT-103-Zugänge, SSH-/Firewall-Baseline, Reboot und lokales Backup abgenommen; NPM 2.15.1 routet öffentlichen OIDC- und internen Admin-vHost getrennt; öffentliche Keycloak-Administration liefert 403 | Fail2Ban-Jails/Bans und Wiederanlauf; hostunabhängige Backupkopie/Restore-Test |
|
||||
| Keycloak | CT 102 unter `10.0.0.4` reproduzierbar per Ansible aufgebaut; gepinntes Keycloak 26.2.4 und PostgreSQL 16.15, Restore von CT 104, Container-/DB-Härtung, NPM-Cutover, interner Adminzugang, öffentliche Admin-Sperre, realer Vaultwarden-OIDC-Login und LXC-Neustart abgenommen | 14-tägige Stabilisierungsphase, praktischer NPM-Rückfall auf CT 104, Betriebs-/Backup-/Restore-SOP und anschließende Rückfallbereinigung |
|
||||
| Encoding | Konto, Pfade und eigener rootless Store | Workload, Zeitsteuerung, Ressourcenlimits und Lasttest |
|
||||
|
||||
Ein Zielbild, eine Betreiberangabe oder ein erfolgreicher Teiltest gilt nicht als
|
||||
@@ -103,12 +108,17 @@ vollständige Abnahme. Datierte Details stehen im zuständigen Fachdokument.
|
||||
Vor einer neuen öffentlichen KI-Schnittstelle müssen mindestens diese Befunde
|
||||
geschlossen sein:
|
||||
|
||||
1. öffentliche Keycloak-Administration sperren und Vaultwarden-OIDC erneut
|
||||
prüfen;
|
||||
2. VPN-Rollen serverseitig erzwingen und positive sowie negative Zugriffe
|
||||
einschließlich Gateway-INPUT testen;
|
||||
3. Fail2Ban-Jails, kontrollierten Ban und Wiederanlauf nachweisen;
|
||||
4. öffentliche IPv4-/IPv6-Exposure erneut erfassen.
|
||||
1. Fail2Ban-Jails, kontrollierten Ban und Wiederanlauf nachweisen;
|
||||
2. öffentliche IPv4-/IPv6-Exposure erneut erfassen;
|
||||
3. die Keycloak-Stabilisierungsphase sowie den praktischen NPM-Rückfall auf
|
||||
CT 104 abschließen;
|
||||
4. für DNS, Reverse Proxy und Keycloak hostunabhängige Backups und mindestens
|
||||
einen praktisch getesteten Restorepfad festlegen.
|
||||
|
||||
Die öffentliche Keycloak-Administration, der Vaultwarden-OIDC-Pfad und die
|
||||
serverseitigen WireGuard-Rollengrenzen sind zum Stand 14.10.2026 bereits
|
||||
positiv und negativ abgenommen. Sie bleiben Regressionstest, sind aber keine
|
||||
offenen Erstimplementierungen mehr.
|
||||
|
||||
Noch zu entscheiden sind insbesondere:
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
**Titel:** Priorisierte Roadmap für Ghostnet.AI
|
||||
|
||||
**Stand:** 2026-09-24
|
||||
**Stand:** 2026-10-14
|
||||
|
||||
**Status:** Arbeitsgrundlage; Entscheidungstore vor externer Veröffentlichung offen
|
||||
|
||||
@@ -61,25 +61,19 @@ Netzwerkgrenzen, bevor weitere öffentliche Dienste hinzukommen.
|
||||
- WAN- und IPv6-Exposure für Managementports erfassen;
|
||||
- Ergebnisse mit Datum und Negativtests in GNAI-006/GNAI-007 dokumentieren.
|
||||
|
||||
**Ausführungsstand 24.09.2026:** Die externe Keycloak-Administration und die
|
||||
fehlende VPN-Rollenisolierung sind live bestätigt. Die Schlüsseldateien des
|
||||
WireGuard-Gateways wurden auf `0600` gehärtet. Auf CT 103 sind persönlicher und
|
||||
lesender Schlüsselzugang, passwortgeschütztes sudo, SSH-Härtung,
|
||||
LAN/WireGuard-Netzbaseline und der Wiederanlauf nach einem kontrollierten
|
||||
Neustart abgenommen. Ein konsistentes, lokal auf Proxmox gespeichertes
|
||||
Stop-Mode-Backup wurde am 24.09.2026 erstellt, technisch geprüft und der
|
||||
Dienstwiederanlauf abgenommen; hostunabhängige Kopie und Restore-Test bleiben
|
||||
offen. Der DNS-Vorläufer GNAI-012 wurde am 01.10.2026 umgesetzt: CT 100 unter
|
||||
`10.0.0.14` stellt Pi-hole und `auth-admin.ghostnet.selfhost.eu -> 10.0.0.10`
|
||||
bereit; FRITZ!Box-Cutover, LAN-Testclient, Restart/Reboot und lokales
|
||||
Stop-Mode-Backup sind abgenommen. Mobile DuckDuckGo-/IPv6-Umgehung,
|
||||
WireGuard-DNS pro Rolle, vollständige App-/OIDC-Regression und praktischer
|
||||
FRITZ!Box-Rollback bleiben ausdrücklich offen. Für die nächsten wirksamen
|
||||
Änderungen fehlt weiterhin die verbindliche Rolle der beiden vorhandenen
|
||||
VPN-Peers. Die technische Reihenfolge ist nun: internen NPM-Host und Keycloak
|
||||
`hostname-admin` einrichten und testen, öffentlichen `/admin`-Pfad sperren und
|
||||
regressionsprüfen, danach Peers zuordnen und Gateway-Regeln umsetzen sowie die
|
||||
offenen Fail2Ban-Funktionstests abschließen.
|
||||
**Ausführungsstand 14.10.2026:** Pi-hole stellt unter `10.0.0.14` den
|
||||
LAN-Resolver und den internen Split-DNS-Namen für die Keycloak-Administration
|
||||
bereit. CT 102 wurde reproduzierbar mit Ansible, Keycloak 26.2.4 und
|
||||
PostgreSQL 16.15 neu aufgebaut, aus CT 104 wiederhergestellt und über NPM in
|
||||
Betrieb genommen. Öffentlicher Adminzugriff wird abgewiesen; interner
|
||||
Adminzugriff, Vaultwarden-OIDC und LXC-Neustart sind abgenommen. Die
|
||||
WireGuard-Rollenpolicy einschließlich Positiv-, Negativ-, Gateway-INPUT- und
|
||||
Wiederanlauftests ist ebenfalls nachgewiesen. Offen bleiben die
|
||||
Fail2Ban-Funktionstests, eine erneute WAN-/IPv6-Exposure-Erfassung, die
|
||||
Keycloak-Stabilisierungs- und Rückfallabnahme sowie hostunabhängige
|
||||
Backup-/Restore-Nachweise. Pi-hole und NPM besitzen noch keinen vollständigen
|
||||
Ansible-Sollzustand im Repository; vor weiteren Live-Änderungen werden sie in
|
||||
ihre hostbezogenen Projektwurzeln importiert.
|
||||
|
||||
**Fertig, wenn:** Die vier priorisierten Sicherheitsbefunde im zentralen Index
|
||||
als bestanden oder mit ausdrücklich akzeptiertem Restrisiko dokumentiert sind.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Sicherheitsabschluss und Repository-Neustrukturierung
|
||||
|
||||
**Dokumenten-ID:** GNAI-013-PLAN
|
||||
**Stand:** 2026-10-05
|
||||
**Stand:** 2026-10-14
|
||||
**Status:** Aktiver Arbeitsplan
|
||||
|
||||
## 1. Ziel
|
||||
@@ -35,6 +35,25 @@ docs/
|
||||
└── encoding/GNENC-*.md
|
||||
```
|
||||
|
||||
Die ausführbare Infrastruktur ist seit 14.10.2026 ebenfalls hostbezogen:
|
||||
|
||||
```text
|
||||
ghostnet.ai-infrastructure/
|
||||
├── llm-server/ansible/ # physischer KI-Server
|
||||
├── proxmox/
|
||||
│ ├── shared/scripts/ # nur LXC-übergreifende PVE-Helfer
|
||||
│ └── lxc/
|
||||
│ ├── ct-100-dns/ # Hostwurzel; Ansible-Import offen
|
||||
│ ├── ct-102-keycloak/{ansible,scripts}/
|
||||
│ ├── ct-103-reverse-proxy/ # Hostwurzel; Ansible-Import offen
|
||||
│ ├── ct-104-keycloak/ # erhaltener Rückfall-/Referenzstand
|
||||
│ └── ct-105-wireguard/
|
||||
└── shared/ansible/ # gemeinsam genutzte Rollen und Pins
|
||||
```
|
||||
|
||||
Inventare und Playbooks liegen beim Zielhost. `shared/` enthält keine
|
||||
hostbezogenen Playbooks oder Inventare.
|
||||
|
||||
## 3. Arbeitscheckliste
|
||||
|
||||
### A. Dokumentation und lokale Geheimnisse
|
||||
@@ -49,6 +68,10 @@ docs/
|
||||
- [x] Sicherstellen, dass keine WireGuard-PrivateKeys oder PresharedKeys in
|
||||
versionierten Dateien oder der Git-Historie liegen.
|
||||
- [x] Bereits offengelegte Altpeers über wg-easy rotieren oder widerrufen.
|
||||
- [x] Wiederverwendbare LXC-Baseline als versionierten, idempotenten
|
||||
Proxmox-Post-install-Hook erstellen, auf CT 104 kontrolliert erproben und
|
||||
dokumentieren. Der Hook darf keine privaten Schlüssel enthalten und SSH
|
||||
nicht sperren, bevor individuelle Schlüsselzugänge geprüft wurden.
|
||||
|
||||
### B. WireGuard-Arbeitsplatz und Gateway
|
||||
|
||||
@@ -74,14 +97,23 @@ docs/
|
||||
|
||||
### C. Interner Keycloak-Adminzugang
|
||||
|
||||
- [ ] Aktuellen Keycloak-Stand und wirksame Hostname-/Proxy-Konfiguration
|
||||
- [x] Sichere SSH-Zugänge für den Keycloak-LXC nach der gemeinsamen
|
||||
LXC-Baseline einrichten und positiv wie negativ testen.
|
||||
- [x] Aktuellen Keycloak-Stand und wirksame Hostname-/Proxy-Konfiguration
|
||||
sichern und inventarisieren.
|
||||
- [ ] Internen NPM-vHost für `auth-admin.ghostnet.selfhost.eu` mit gültigem TLS
|
||||
- [x] Leserechte der lokalen Keycloak-H2-Datenbank sofort auf Root begrenzen
|
||||
und den Agenten-Negativtest wiederholen.
|
||||
- [x] H2-`dev-file` und `start-dev` durch eine gesicherte
|
||||
Podman-/PostgreSQL-Blue/Green-Migration nach GNAI-015 ersetzen.
|
||||
- [x] Keycloak 26.2.4 im gepinnten Produktionsimage betreiben; ein späteres
|
||||
26.x-Upgrade bleibt ein eigener kontrollierter Wartungsschritt.
|
||||
- [x] Internen NPM-vHost für `auth-admin.ghostnet.selfhost.eu` mit gültigem TLS
|
||||
und ohne öffentliche Freigabe einrichten.
|
||||
- [ ] Keycloak `hostname-admin` passend zur installierten Version setzen.
|
||||
- [ ] Internen Adminlogin über LAN und berechtigte VPN-Rolle positiv testen.
|
||||
- [ ] Öffentliche `/admin`-GUI und Admin-API über HTTP und HTTPS sperren.
|
||||
- [ ] Externe Negativtests sowie Vaultwarden-/OIDC-Regression durchführen.
|
||||
- [x] Keycloak `hostname-admin` passend zur installierten Version setzen.
|
||||
- [x] Internen Adminlogin über LAN positiv testen; Server-Admin-VPN nutzt
|
||||
denselben per Gateway freigegebenen NPM-Zielpfad.
|
||||
- [x] Öffentliche `/admin`-GUI und Admin-API über HTTPS sperren.
|
||||
- [x] Externe Negativtests sowie reale Vaultwarden-/OIDC-Regression durchführen.
|
||||
|
||||
### D. Reverse Proxy, Fail2Ban und Recovery
|
||||
|
||||
@@ -96,7 +128,7 @@ docs/
|
||||
|
||||
- [ ] KI-Server kontrolliert einschalten und Resolver auf den beschlossenen
|
||||
Pi-hole-Pfad abstimmen.
|
||||
- [ ] Lokale Ansible-`.venv` wegen des veralteten Repository-Pfads neu
|
||||
- [x] Lokale Ansible-`.venv` wegen des veralteten Repository-Pfads neu
|
||||
erstellen und alle Syntaxprüfungen regulär ausführen.
|
||||
- [ ] Lesenden Preflight sowie Host-/ROCm-Verifikation wiederholen.
|
||||
- [ ] Internes, nicht öffentliches `llama.cpp`-Testing-Vertical-Slice nach
|
||||
@@ -173,10 +205,121 @@ docs/
|
||||
der SSH-, Firewall- und wg-easy-Konfiguration ohne fehlgeschlagene Units.
|
||||
Systemupdates erfolgen künftig kontrolliert manuell nach Simulation und mit
|
||||
dokumentiertem Health-Check; unbeaufsichtigte Upgrades bleiben deaktiviert.
|
||||
- Die Keycloak-Vorprüfung bestätigt Split-DNS zum internen Adminnamen, aber
|
||||
noch keinen passenden NPM-TLS-vHost. Der öffentliche `/admin`-Pfad bleibt
|
||||
erreichbar; Backendtests mit beiden Hostnamen erzwingen weiterhin den
|
||||
öffentlichen Frontendnamen und die Master-Realm-Discovery nennt den
|
||||
öffentlichen Issuer. Version und startwirksame `KC_HOSTNAME*`-Parameter
|
||||
müssen als Nächstes direkt im Keycloak-LXC inventarisiert werden.
|
||||
- CT 102 wurde über den unabhängig bestätigten ED25519-Hostschlüssel in die
|
||||
gemeinsame SSH-Architektur aufgenommen. `ghost-agent` besitzt weder
|
||||
Zusatzgruppen noch sudo; `ghost` verwendet einen eigenen
|
||||
passphrasegeschützten Schlüssel und passwortgeschütztes sudo. Root-,
|
||||
Passwort- und Forwarding-Zugriffe wurden nach dem Dienstneustart extern
|
||||
abgelehnt, beide Schlüsselzugänge positiv geprüft. Der vollständige
|
||||
LXC-Wiederanlauf folgt erst nach einem aktuellen Stop-Backup.
|
||||
- Beim Bootstrap zeigte sich eine bestehende numerische Eigentümerschaft von
|
||||
464 Keycloak-Einträgen unter UID 1001/GID 118. Das zunächst mit UID 1001
|
||||
angelegte Administratorkonto wurde nach Prüfung freier IDs auf UID/GID 1101
|
||||
verschoben. Danach konnte `ghost` weder Programm noch Konfiguration
|
||||
schreiben; Agenten- und persönlicher Zugang, sudo, Passwortsperre sowie
|
||||
Keycloak und SSH wurden erneut erfolgreich geprüft. Die historischen
|
||||
Eigentümer werden erst nach Backup und gesonderter Betriebsprüfung bewertet.
|
||||
- Der neue Lesekanal bestätigt Keycloak 26.2.4 als nativen, rootgeführten
|
||||
systemd-Dienst aus `/opt/keycloak`. Der Dienst verwendet derzeit
|
||||
`kc.sh start-dev`, HTTP auf Port 8080, `proxy=edge`,
|
||||
`proxy-headers=xforwarded`, den festen öffentlichen Hostnamen und noch kein
|
||||
`hostname-admin`. Prüfsummen von Unit und Konfiguration wurden erfasst.
|
||||
Der Entwicklungsstart wird als eigener Härtungspunkt behandelt und nicht
|
||||
unkontrolliert mit der Adminpfad-Änderung vermischt.
|
||||
- Ein vollständiges Proxmox-Backup von CT 102 wurde im Modus `Stop` erstellt.
|
||||
Der LXC startete um 19:37 Uhr mit unveränderten Konfigurationsprüfsummen,
|
||||
UID-Trennung und Listenern. Keycloak und beide SSH-Units waren aktiv und
|
||||
aktiviert; Agenten- und persönlicher Schlüsselzugang, passwortgeschütztes
|
||||
sudo sowie Root-, Passwort- und Forwarding-Negativtests bestanden erneut.
|
||||
- Die Anwendung verwendet ausschließlich die lokale H2-`dev-file`-Datenbank;
|
||||
eigene Provider oder Themes wurden nicht gefunden. Daten- und
|
||||
H2-Verzeichnis sowie Datenbank- und Trace-Datei sind `0755` beziehungsweise
|
||||
`0644`; `ghost-agent` konnte die Identitätsdatenbank lesen. Dieser
|
||||
unmittelbare Rechtebefund wird vor weiteren Keycloak-Arbeiten geschlossen.
|
||||
- GNAI-015 legt die Migration als Blue/Green-Verfahren in CT 104 mit
|
||||
`10.0.0.16` fest. CT-ID und IP wurden bei der Erstellung auf Kollisionen
|
||||
geprüft. Import auf gleicher Version,
|
||||
PostgreSQL-Meilenstein, separates Versionsupgrade, Adminpfad-Trennung,
|
||||
NPM-Cutover, 14-tägiger Rückfallstand und Restore-Test besitzen eigene
|
||||
Abnahmeschranken.
|
||||
- Die H2-Verzeichnisse sind nun `0700`, die Datenbankdateien `0600` und
|
||||
systemd setzt für Keycloak `UMask=0077`. `ghost-agent` konnte das
|
||||
Datenverzeichnis danach weder traversieren noch die Datenbank lesen.
|
||||
Keycloak, SSH, lokale und öffentliche Discovery blieben funktionsfähig.
|
||||
Die zuvor irrtümlich dokumentierte CT-ID 106 wurde auf die tatsächlich
|
||||
verwendete CT-ID 104 korrigiert; die Ziel-IP bleibt `10.0.0.16`.
|
||||
- CT 104 wurde am 06.10.2026 über das Community-Script als unprivilegierter
|
||||
Debian-13-LXC mit 12 GB Datenträger, zwei CPU-Kernen, 4096 MiB RAM und
|
||||
vorinstalliertem Podman erstellt. IPv4, Internet- und DNS-Zugriff waren laut
|
||||
Installationsprotokoll erfolgreich; IPv6 war wie vorgesehen nicht
|
||||
verbunden. Podman 5.4.2, die UID-Map `0 -> 100000` und ein fehlerfreier
|
||||
systemd-Zustand wurden anschließend bestätigt. Der ED25519-Hostschlüssel
|
||||
`SHA256:AmLKE4H/ulGBQqC/J7mGO7rcu5U/xHtyKk45hhBuNRw` stimmte zwischen
|
||||
Proxmox-Konsole und unabhängiger Messung vom Admin-Laptop überein.
|
||||
- Das erste Stop-Baseline-Backup von CT 104 wurde erfolgreich als
|
||||
`/media/datenhdd2/dump/vzdump-lxc-104-2026_10_06-14_49_53.tar.zst`
|
||||
erstellt; der LXC war nach 16 Sekunden wieder online. Vor der
|
||||
Benutzeranlage wurde die Swap-Größe von 512 auf 1024 MiB angehoben. Der
|
||||
folgende Neustart bestätigte aktive SSH-Units, Podman 5.4.2 und keine
|
||||
fehlgeschlagenen systemd-Units. Hostname `keycloak` und die vorhandenen
|
||||
Community-Script-Tags bleiben auf Betreiberwunsch unverändert; CT 102 und
|
||||
CT 104 werden deshalb in Zugängen und Dokumentation stets über CT-ID und IP
|
||||
unterschieden. Für UID/GID 1000, 1001 und 1101 wurden keine vorhandenen
|
||||
Konten, Gruppen oder Dateieigentümer gefunden.
|
||||
- `ghost-agent` wurde auf CT 104 als UID/GID 1000 mit gesperrtem Passwort,
|
||||
eigener Primärgruppe und ohne Zusatzgruppen eingerichtet. Der dedizierte
|
||||
Schlüssel besitzt den Fingerprint
|
||||
`SHA256:GCCeciGznhKQjb8xRWm6qbqKNdsuIJAQ1+cgNzcKPmM`; Rechte,
|
||||
strikte Hostschlüsselbindung und der lokale Alias `ghostnet-ct104-agent`
|
||||
wurden praktisch geprüft. Eine Kontrollverbindung zu CT 102 bestätigte,
|
||||
dass dessen bestehender Agentenschlüssel mit Fingerprint
|
||||
`SHA256:PXJ0yuEyVoaW2CI5IVax5DYjM8u26Qfyyx/pIrmIkx0` durch den kurzzeitigen
|
||||
Bedienfehler nicht verändert wurde.
|
||||
- `ghost` wurde auf CT 104 als UID/GID 1001 mit eigenem
|
||||
passphrasegeschütztem Schlüssel und passwortgeschütztem sudo eingerichtet;
|
||||
Schlüsselfingerprint
|
||||
`SHA256:4OgAGXgf6M/uQFnGdDTcZ+5qturtdWDTUHb5FCjdz+8`. Das SSH-Drop-in
|
||||
`99-security.conf` erlaubt nur `ghost-agent` und `ghost`, verlangt Public
|
||||
Key und sperrt Root-, Passwort-, X11-, Agent-, TCP-, StreamLocal- und
|
||||
Tunnel-Forwarding. Positivtests beider Rollen, sudo, Root-/Passwort- und
|
||||
Forwarding-Negativtests sowie ein vollständiger LXC-Neustart mit aktiven
|
||||
SSH-Units, Podman 5.4.2 und ohne fehlgeschlagene systemd-Units wurden
|
||||
erfolgreich abgenommen.
|
||||
- Der secretfreie Post-install-Hook
|
||||
`proxmox/shared/scripts/ghostnet-lxc-baseline.sh` wurde mit Modus `0750` als
|
||||
`/usr/local/sbin/ghostnet-lxc-baseline.sh` auf Proxmox installiert. Seine
|
||||
geprüfte SHA-256 lautet
|
||||
`a0ee32a8fc2854c29a83b17b4e0cdb5ad55cde36de92d562f6bd890dc93ae89e`.
|
||||
Der idempotente Lauf gegen CT 104 übersprang die bereits vorhandenen Pakete,
|
||||
validierte beide Konten samt Passwortzustand und bewahrte die vorhandenen
|
||||
Schlüsseldateien. Anschließende Positiv- und Negativtests bestätigten den
|
||||
Agentenzugang sowie die fortbestehenden Root-, Passwort- und
|
||||
Forwarding-Sperren.
|
||||
- Auf CT 104 wurde eine eigene nftables-Tabelle `inet ghostnet_filter` mit
|
||||
INPUT- und FORWARD-Drop eingerichtet. Ein systemd-Override ersetzt das
|
||||
distributionsseitige vollständige `flush ruleset` beim Stoppen durch das
|
||||
Entfernen ausschließlich der Ghostnet-Tabelle. Die persistent neu geladene
|
||||
Konfiguration erlaubte SSH und ICMP, verwarf TCP 8080 vom Admin-Laptop und
|
||||
ließ denselben geschlossenen Port von CT 103 bis zum erwarteten sofortigen
|
||||
TCP-Reset passieren. IPv4-Forwarding ist für das geplante Podman-Netz aktiv,
|
||||
IPv6-Forwarding bleibt aus. Die abschließende Prüfung mit einem
|
||||
digest-gepinnten BusyBox-Quadlet belegte Aardvark-DNS, die feste
|
||||
Containeradresse `10.89.0.20`, Netavark-DNAT und den Quellfilter: CT 103
|
||||
erhielt vor und nach einem vollständigen LXC-Neustart unmittelbar eine
|
||||
HTTP-Antwort, der Admin-Laptop lief jeweils in einen Timeout. nftables,
|
||||
Podman-Netz, Testcontainer und der gehärtete SSH-Zugang kamen nach dem
|
||||
Neustart automatisch und fehlerfrei wieder.
|
||||
|
||||
## 5. Aktuell bestätigte Blocker
|
||||
|
||||
- Der öffentliche Keycloak-Adminpfad ist noch erreichbar.
|
||||
- Der öffentliche Keycloak-Adminpfad ist gesperrt; interner Adminzugang und
|
||||
Vaultwarden-OIDC sind abgenommen.
|
||||
- Die native WireGuard-Rollenpolicy ist aktiv und die Legacy-Regeln sind
|
||||
bereinigt; Fremdnetz-, Positiv-, Negativ- und Wiederanlauftests sind
|
||||
abgeschlossen.
|
||||
@@ -184,6 +327,8 @@ docs/
|
||||
abgenommen.
|
||||
- Der KI-Server ist bewusst ausgeschaltet und wird für die Punkte A bis D
|
||||
nicht benötigt.
|
||||
- CT 102 befindet sich in der 14-tägigen Stabilisierungsphase; der praktische
|
||||
NPM-Rückfall auf den ausgeschalteten CT 104 bleibt offen.
|
||||
|
||||
## 6. Pflege
|
||||
|
||||
|
||||
@@ -172,7 +172,13 @@ zusätzliche per-Workstation- oder per-Peer-SSH-Allowlist.
|
||||
|
||||
Familie erhält ausdrücklich Heimnetz-Vollzugriff. Server Admins und LLM Admins erhalten nur die beschlossenen Zielhosts. Native nftables-Sets auf CT 105 setzen diese Rechte anhand von Peer-IP und Zielhost durch, während clientseitige Allowed IPs nur Routing festlegen. Ein Benutzer darf die Serverbeschränkung nicht durch Änderungen an seinem lokalen Profil umgehen können. Für Gateway-eigene Dienste wird die INPUT-Chain separat anhand zugeordneter VPN-Quellen begrenzt.
|
||||
|
||||
Die native Rollenpolicy wurde am 05.10.2026 zunächst für den alten und neuen Server-Admin-Laptop-Peer geladen. Die parallelen wg-easy-`iptables-legacy`-Hooks und Laufzeitregeln wurden entfernt und blieben nach einem kontrollierten wg-easy-Neustart fort. Positive und negative Abnahme stehen noch aus. Negative Tests müssen mit absichtlich erweitertem Client-Routing und aktivem Zielservice stattfinden. Normale LLM-Anwendungsnutzer erhalten keinen Managementpeer. Geräteprofile werden durch Administratoren erstellt und geschützt verteilt. Die GUI ist für Familie und Server Admins vorgesehen.
|
||||
Die native Rollenpolicy wurde am 05.10.2026 für den Server-Admin-Laptop
|
||||
abgenommen. Die parallelen wg-easy-`iptables-legacy`-Hooks und Laufzeitregeln
|
||||
wurden entfernt; Fremdnetz-, Positiv-, Negativ-, Dienst- und
|
||||
LXC-Wiederanlauftests bestanden. Der Altpeer wurde danach gelöscht;
|
||||
`ghost-laptop` ist der einzige aktive Peer. Normale LLM-Anwendungsnutzer
|
||||
erhalten keinen Managementpeer. Geräteprofile werden durch Administratoren
|
||||
erstellt und geschützt verteilt.
|
||||
|
||||
MASQUERADE wird mangels Rückroute verwendet; es verbirgt gegenüber LAN-Diensten die ursprüngliche Peer-IP. Zielkontrolle muss daher am VPN-Gateway vor NAT erfolgen. Lokale LAN-Quellen lassen sich nicht allein durch die VPN-Rolle gruppengenau zuordnen; hierfür sind zusätzliche Regeln/Identitäten nötig.
|
||||
|
||||
|
||||
@@ -229,7 +229,13 @@ WireGuard ist die dedizierte Zugangsschicht für privilegierte Netzwerkzugriffe.
|
||||
| Server Admin | Proxmox, Service-LXCs und KI-Server; NPM und wg-easy eingeschlossen |
|
||||
| LLM Admin | ausschließlich KI-Server `10.0.0.90` |
|
||||
|
||||
Profile werden pro Gerät vom Administrator erstellt und verteilt. Benutzer benötigen für den Profilbezug keine GUI-Anmeldung. Serverseitige **Firewall Allowed IPs** in wg-easy sollen die Zielrechte durchsetzen; clientseitige **Allowed IPs** steuern Split-Tunneling. Per-Client Firewall ist aktuell deaktiviert, Rollenisolierung nicht abgenommen. Gateway-GUI/SSH benötigen zusätzlich passende INPUT-Regeln. Die alte Full-Admin-Bezeichnung wird nicht automatisch einer der neuen Rollen gleichgesetzt.
|
||||
Profile werden pro Gerät vom Administrator erstellt und verteilt. Benutzer
|
||||
benötigen für den Profilbezug keine GUI-Anmeldung. Clientseitige **Allowed
|
||||
IPs** steuern Split-Tunneling; native nftables-Sets auf CT 105 setzen die
|
||||
Zielrechte anhand der Peer-IP serverseitig durch. Die experimentelle
|
||||
wg-easy-Per-Client-Firewall bleibt deaktiviert. Der Server-Admin-Peer
|
||||
`ghost-laptop` wurde einschließlich INPUT-, FORWARD-, NAT- und Negativtests
|
||||
abgenommen.
|
||||
|
||||
Das Gateway nutzt MASQUERADE für VPN→LAN; die Fritzbox besitzt keine Rückroute zum VPN-Netz. LAN-Dienste sehen dadurch die Quelladresse des Gateways. Die serverseitige Peer-Zielkontrolle muss vor NAT greifen. Installation, Hooks und Abnahme stehen in GNAI-007.
|
||||
|
||||
|
||||
@@ -189,10 +189,12 @@ verwendet. Es soll den vollständigen Sollzustand des KI-Servers, beide
|
||||
Laufzeitumgebungen und die zugehörigen Workflows besitzen. Damit verändern
|
||||
nicht mehrere unabhängige Deployment-Repositories denselben Host.
|
||||
|
||||
Vorgesehene grobe Struktur:
|
||||
Die ursprünglich vorgesehene Struktur wurde am 14.10.2026 unter einen eigenen
|
||||
Host-Root verschoben. Innerhalb des LLM-Server-Projekts bleibt die bewährte
|
||||
Ansible-Gliederung erhalten:
|
||||
|
||||
```text
|
||||
ansible/
|
||||
llm-server/ansible/
|
||||
├── ansible.cfg
|
||||
├── collections/
|
||||
│ └── requirements.yml
|
||||
@@ -352,7 +354,7 @@ Gemeinsame Hostaufgaben und umgebungsspezifische Anwendungsaufgaben werden
|
||||
getrennt:
|
||||
|
||||
```text
|
||||
ansible/
|
||||
llm-server/ansible/
|
||||
├── playbooks/
|
||||
│ ├── host.yml
|
||||
│ ├── ai.yml
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
**Titel:** `llama.cpp`-Serving mit ROCm und rootless Podman
|
||||
**Stand:** 2026-09-21
|
||||
**Status:** Technische Sollkonfiguration für Testing; noch nicht implementiert
|
||||
**Referenzen:** [Modellbewertung](GNAI-009-PLAN_Llama-Model-Evaluation.md), [Ansible-Deployment](GNAI-008-PLAN_Ansible-Deployment.md), [Zielarchitektur](GNAI-004-INFRA_Architecture.md), [Ansible-Anleitung](../../../../ansible/README.md)
|
||||
**Referenzen:** [Modellbewertung](GNAI-009-PLAN_Llama-Model-Evaluation.md), [Ansible-Deployment](GNAI-008-PLAN_Ansible-Deployment.md), [Zielarchitektur](GNAI-004-INFRA_Architecture.md), [Ansible-Anleitung](../../../../llm-server/ansible/README.md)
|
||||
|
||||
> Dieses Dokument beschreibt den vorgesehenen technischen Sollzustand. Es ist
|
||||
> keine Betriebs-SOP und kein Nachweis, dass Modell, Quadlet oder Inferenz
|
||||
|
||||
@@ -80,7 +80,7 @@ in GPU-Gruppen und keine `/dev/kfd`- oder `/dev/dri`-Weitergabe.
|
||||
Vorgesehene Ansible-Struktur:
|
||||
|
||||
```text
|
||||
ansible/
|
||||
llm-server/ansible/
|
||||
├── playbooks/
|
||||
│ ├── host.yml
|
||||
│ ├── ai.yml
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
**Dokumenten-ID:** GNAI-011-SEC
|
||||
**Titel:** Gemeinsame SSH-Sicherheitsarchitektur der LXCs auf Ghostnet Proxmox
|
||||
**Status:** Legacy-Root-Bestand und Zielbild definiert; CT-103-SSH-Rollout einschließlich Neustart abgenommen
|
||||
**Status:** Legacy-Root-Bestand und Zielbild definiert; CT-102, CT-103 und CT-104 einschließlich Neustart abgenommen
|
||||
**Geltungsbereich:** LXCs auf Proxmox `10.0.0.2`; nicht der Proxmox-Host selbst
|
||||
**Referenzen:** [KI-Host-SSH](../llm-server/host/GNAI-003-INFRA_Users.md), [Zugriffskonzept](../GNAI-005-SEC_Access-Control.md), [CT 103](lxc/ct-103-reverse-proxy/GNAI-006-SEC_Reverse-Proxy.md), [CT 105](lxc/ct-105-wireguard/GNAI-007-SEC_VPN.md)
|
||||
**Referenzen:** [KI-Host-SSH](../llm-server/host/GNAI-003-INFRA_Users.md), [Zugriffskonzept](../GNAI-005-SEC_Access-Control.md), [CT 102](lxc/ct-102-keycloak/GNAI-014-SEC_Keycloak.md), [CT-104-Migration](lxc/ct-102-keycloak/GNAI-015-PLAN_Keycloak-Podman-Migration.md), [CT 103](lxc/ct-103-reverse-proxy/GNAI-006-SEC_Reverse-Proxy.md), [CT 105](lxc/ct-105-wireguard/GNAI-007-SEC_VPN.md)
|
||||
|
||||
## Änderungsverlauf
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
| 2026-09-24 | 0.7 | Codex | Effektive CT-103-INPUT-DROP-Policy und LAN-Begrenzung von SSH bestätigt; engere Management-Allowlist wegen dynamischer Workstation-Adresse zurückgestellt. |
|
||||
| 2026-09-24 | 0.8 | Codex | Einfache Host-Netzbaseline für alle Server festgelegt: SSH aus LAN und via WireGuard zulassen, keine Workstation-`/32`-Allowlist; CT 103 serverseitig abgenommen. |
|
||||
| 2026-09-24 | 1.0 | Codex | Kontrollierten CT-103-Neustart, Dienstwiederanlauf, Firewallwirkung, öffentliche Proxy-Pfade sowie Agenten- und persönlichen SSH-Zugang erfolgreich abgenommen. |
|
||||
| 2026-10-05 | 1.1 | Codex | CT-102-Hostschlüssel, getrennte Agenten-/Administratorschlüssel, SSH-Baseline, passwortgeschütztes sudo sowie Positiv- und Negativtests abgenommen. |
|
||||
| 2026-10-05 | 1.2 | Codex | CT-102-UID-Kollision korrigiert und Stop-Backup-Wiederanlauf einschließlich beider Zugänge, sudo und Negativtests abgenommen. |
|
||||
| 2026-10-06 | 1.3 | Codex | CT-104-Hostschlüssel, getrennte Agenten-/Administratorschlüssel, SSH-Baseline, passwortgeschütztes sudo sowie Positiv-, Negativ- und Neustarttests abgenommen. |
|
||||
|
||||
## 1. Zweck und Abgrenzung
|
||||
|
||||
@@ -36,7 +39,9 @@ Verfahren.
|
||||
|
||||
| System | Rolle | SSH-Einordnung |
|
||||
| --- | --- | --- |
|
||||
| CT 102 `10.0.0.4` | Keycloak und öffentliches OIDC-Backend | `ghost` als persönlicher Administrator mit abweichender UID/GID 1101 wegen bestehender Keycloak-Dateien unter UID 1001; `ghost-agent` als unprivilegiertes Lesekonto; Root-, Passwort- und Forwarding-Zugriffe deaktiviert; Stop-Backup-Wiederanlauf bestanden |
|
||||
| CT 103 `10.0.0.10` | Reverse Proxy, NPM und Fail2Ban; öffentliches HTTP(S)-Ingress | `ghost` als persönlicher Administrator und `ghost-agent` als unprivilegiertes Lesekonto; Root- und Passwort-SSH deaktiviert; Proxmox-Konsole bleibt Recovery-Weg |
|
||||
| CT 104 `10.0.0.16` | Blue/Green-Ziel für Keycloak auf Podman/PostgreSQL | `ghost` UID/GID 1001 mit passwortgeschütztem sudo und `ghost-agent` UID/GID 1000 ohne sudo; getrennte Schlüssel und strikte Hostschlüsselbindung; Root-, Passwort- und Forwarding-Zugriffe deaktiviert; Neustartabnahme bestanden |
|
||||
| CT 105 `10.0.0.15` | WireGuard-Gateway und wg-easy | verifizierte Ausnahme: `admin` UID 1000, Key-Login und sudo/`NOPASSWD`; vollständige Baseline noch abnehmen |
|
||||
| weitere Legacy-LXCs | unterschiedliche Plattformdienste | laut Betreiber nur `root`; erst nach Inventaraufnahme einzeln migrieren, keine ungeprüfte Massenänderung |
|
||||
| KI-Host `10.0.0.90` | physischer KI-Server | eigene Dokumentation unter `platform/llm-server/` |
|
||||
@@ -122,11 +127,14 @@ AuthenticationMethods publickey
|
||||
X11Forwarding no
|
||||
AllowAgentForwarding no
|
||||
AllowTcpForwarding no
|
||||
AllowStreamLocalForwarding no
|
||||
GatewayPorts no
|
||||
PermitTunnel no
|
||||
DisableForwarding yes
|
||||
|
||||
MaxAuthTries 3
|
||||
LoginGraceTime 30
|
||||
LogLevel VERBOSE
|
||||
```
|
||||
|
||||
Abweichungen benötigen einen dokumentierten Anwendungsfall. Insbesondere
|
||||
|
||||
@@ -8,5 +8,15 @@ Hypervisors selbst.
|
||||
| --- | --- |
|
||||
| [GNAI-011](GNAI-011-SEC_LXC-SSH-Architecture.md) | gemeinsame SSH-Sicherheitsarchitektur und Rolloutverfahren der LXCs |
|
||||
| [GNAI-012 / CT 100](lxc/ct-100-dns/GNAI-012-PLAN_Pi-hole-DNS-Handoff.md) | `10.0.0.14`: Pi-hole-Quadlet, FRITZ!Box-DNS, Split-DNS, Abnahme und Rollback |
|
||||
| [GNAI-014 / CT 102](lxc/ct-102-keycloak/GNAI-014-SEC_Keycloak.md) | `10.0.0.4`: Keycloak, sichere Administration und OIDC-/Adminpfad-Trennung |
|
||||
| [GNAI-015 / Keycloak-Migration](lxc/ct-102-keycloak/GNAI-015-PLAN_Keycloak-Podman-Migration.md) | Blue/Green-Plan für Podman, PostgreSQL, Versionsupgrade und Rückfall |
|
||||
| [CT-102-Automation](../../../proxmox/lxc/ct-102-keycloak/README.md) | produktives Keycloak-Ansible-Projekt und CT-spezifische PVE-Helfer |
|
||||
| [CT-104-Rückfallartefakte](../../../proxmox/lxc/ct-104-keycloak/README.md) | erhaltene Blue/Green-Referenz unter `10.0.0.16` |
|
||||
| [GNAI-006 / CT 103](lxc/ct-103-reverse-proxy/GNAI-006-SEC_Reverse-Proxy.md) | `10.0.0.10`: Reverse Proxy, Keycloak-Adminpfad und Fail2Ban |
|
||||
| [GNAI-007 / CT 105](lxc/ct-105-wireguard/GNAI-007-SEC_VPN.md) | `10.0.0.15`: WireGuard-Gateway, Rollen-Firewall und Clientverfahren |
|
||||
|
||||
Die versionierte Quelle für den optionalen, secretfreien
|
||||
Community-Scripts-Post-install-Hook liegt unter
|
||||
[`proxmox/shared/scripts/ghostnet-lxc-baseline.sh`](../../../proxmox/shared/scripts/ghostnet-lxc-baseline.sh).
|
||||
Einsatzgrenzen und Installation beschreibt die zugehörige
|
||||
[`README`](../../../proxmox/lxc/ct-102-keycloak/scripts/README.md).
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
**Dokumenten-ID:** GNAI-012-PLAN
|
||||
**Titel:** Pi-hole-LXC, FRITZ!Box-DNS und Split-DNS für interne Dienste
|
||||
**Status:** Implementiert und im LAN produktiv; klar ausgewiesene Folgepunkte für WireGuard, mobile DNS-Umgehung und NPM/Keycloak
|
||||
**Stand:** 01.10.2026
|
||||
**Status:** Implementiert und im LAN produktiv; Ansible-Import und klar ausgewiesene Folgepunkte offen
|
||||
**Stand:** 14.10.2026
|
||||
**Referenzen:** [Projekt-Roadmap](../../../../GNAI-001-PLAN_Project-Roadmap.md), [Zugriffskonzept](../../../GNAI-005-SEC_Access-Control.md), [LXC-SSH-Architektur](../../GNAI-011-SEC_LXC-SSH-Architecture.md), [Reverse Proxy](../ct-103-reverse-proxy/GNAI-006-SEC_Reverse-Proxy.md), [WireGuard](../ct-105-wireguard/GNAI-007-SEC_VPN.md)
|
||||
|
||||
## 1. Auftrag und Umsetzungsergebnis
|
||||
@@ -425,6 +425,30 @@ kann nun den internen NPM-Proxy-Host und Keycloak `hostname-admin` einrichten,
|
||||
den internen Login testen und danach `auth.ghostnet.selfhost.eu/admin` für
|
||||
öffentliche Quellen sperren.
|
||||
|
||||
### 10.2 Lesende Wiederholungsprüfung vom 14.10.2026
|
||||
|
||||
Die erneute Live-Inventur bestätigte einen fehlerfreien LXC ohne fehlgeschlagene
|
||||
Units, den aktiven Pi-hole-Quadlet-Dienst, UDP/TCP-DNS, TCP 80/443, den
|
||||
Split-DNS-Eintrag sowie öffentliche Namensauflösung. Image-Tag und Digest,
|
||||
Quad9-Upstreams, lokaler Listening-Modus, deaktiviertes DHCP/NTP, das
|
||||
Podman-Secret und die nftables-INPUT-Drop-Policy stimmen weiterhin mit dem
|
||||
dokumentierten Stand überein. Der produktive Keycloak-Verifier bestätigte
|
||||
zusätzlich den inzwischen fertiggestellten internen HTTPS-Adminpfad und die
|
||||
öffentliche Admin-Sperre.
|
||||
|
||||
Zwei Konfigurationslücken bleiben vor weiteren Änderungen zu schließen:
|
||||
|
||||
- Der Pi-hole-Sollzustand ist noch nicht als Ansible-Projekt implementiert.
|
||||
Die Hostwurzel liegt unter `proxmox/lxc/ct-100-dns/`; Quadlet, Firewall, SSH
|
||||
und ein rein lesender Verifier müssen vor der nächsten Live-Änderung
|
||||
übernommen werden.
|
||||
- Die wirksame SSH-Konfiguration sperrt Root-, Passwort-,
|
||||
Keyboard-Interactive-, Agent-, TCP-, X11- und Tunnel-Forwarding. Anders als
|
||||
die aktuelle gemeinsame LXC-Baseline setzt sie jedoch weder
|
||||
`AllowStreamLocalForwarding no` noch `DisableForwarding yes` ausdrücklich.
|
||||
Dieser begrenzte Hardening-Drift wird beim Ansible-Import geschlossen und
|
||||
anschließend positiv und negativ geprüft.
|
||||
|
||||
## 11. Primärquellen
|
||||
|
||||
- [Pi-hole – Voraussetzungen](https://docs.pi-hole.net/main/prerequisites/)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# CT 102 – Keycloak
|
||||
|
||||
**Dokumenten-ID:** GNAI-014-SEC
|
||||
**Zweck:** Keycloak-LXC, sichere Administration und Trennung des internen
|
||||
Adminzugangs vom öffentlichen OIDC-Frontend
|
||||
**Status:** Historischer Ausgangsstand des ursprünglichen CT 102; dieser LXC
|
||||
wurde durch den am 14.10.2026 produktiv abgenommenen Ansible-Neuaufbau ersetzt
|
||||
|
||||
**Migrationsplan:** [GNAI-015 – Blue/Green auf Podman und PostgreSQL](GNAI-015-PLAN_Keycloak-Podman-Migration.md)
|
||||
|
||||
## 1. Bestätigter Bestand
|
||||
|
||||
> Die folgende Bestandsaufnahme beschreibt den inzwischen ersetzten
|
||||
> Debian-12-/H2-Stand. Der aktuelle produktive Zustand und seine Abnahme stehen
|
||||
> in [GNAI-016](GNAI-016-PLAN_Keycloak-Ansible-Rebuild.md).
|
||||
|
||||
| Merkmal | Stand |
|
||||
| --- | --- |
|
||||
| Proxmox-Gast | CT 102 |
|
||||
| Hostname | `keycloak` |
|
||||
| Betriebssystem | Debian 12 (Bookworm) |
|
||||
| IPv4 / Backend | `10.0.0.4`, Keycloak über HTTP-Port 8080 |
|
||||
| Öffentlicher Name | `auth.ghostnet.selfhost.eu` über NPM/CT 103 |
|
||||
| Interner Adminname | `auth-admin.ghostnet.selfhost.eu` löst intern zu `10.0.0.10` auf; NPM-vHost noch nicht eingerichtet |
|
||||
| Keycloak-Version | 26.2.4 auf Eclipse Adoptium OpenJDK 21.0.12.1 |
|
||||
| Deployment | native Installation unter `/opt/keycloak`; aktivierter systemd-Dienst läuft als Root und startet `kc.sh start-dev` |
|
||||
| Listener | TCP 8080 auf allen IPv4-/IPv6-Adressen; SSH TCP 22 |
|
||||
| Datenbank | lokale H2-`dev-file`-Datenbank unter `/opt/keycloak/data/h2/`; keine externe Produktionsdatenbank konfiguriert |
|
||||
| Erweiterungen | keine eigenen Provider und keine eigenen Themes gefunden |
|
||||
|
||||
Der ED25519-Hostschlüssel wurde am 05.10.2026 unabhängig über die
|
||||
Proxmox-Konsole bestätigt:
|
||||
|
||||
```text
|
||||
SHA256:340P2GiPtCPnaftf0503B4DwVIL5ouNRFmQGg4V578E
|
||||
```
|
||||
|
||||
Die lesbare Keycloak-Konfiguration enthält derzeit:
|
||||
|
||||
```ini
|
||||
proxy=edge
|
||||
proxy-headers=xforwarded
|
||||
hostname=auth.ghostnet.selfhost.eu
|
||||
hostname-strict=true
|
||||
hostname-strict-https=true
|
||||
http-enabled=true
|
||||
```
|
||||
|
||||
`hostname-admin` fehlt. Der Dienst läuft seit dem 02.09.2026 ohne von systemd
|
||||
registrierten Neustart. Die erfassten SHA-256-Prüfsummen des Ausgangsstands
|
||||
lauten:
|
||||
|
||||
```text
|
||||
0dbefae8007fb0e2cb06dcf2c66af59fa869fa806b4464c9731e11a3ba9d55bf /etc/systemd/system/keycloak.service
|
||||
09e1f693f07d54f2257782ec0b86fde29a90763583e1589ad179d2cb78641e08 /opt/keycloak/conf/keycloak.conf
|
||||
```
|
||||
|
||||
`start-dev` ist ein eigener Härtungsbefund. Die Umstellung auf einen
|
||||
Produktionsstart wird nicht mit der Adminhostname-Änderung vermischt, sondern
|
||||
nach Stop-Backup, Prüfung von Datenbank/TLS/Buildoptionen und einem
|
||||
Rückfallverfahren gesondert geplant.
|
||||
|
||||
Die H2-Dateien `keycloakdb.mv.db` und `keycloakdb.trace.db` waren ebenso wie
|
||||
ihre Elternverzeichnisse mit `0644` beziehungsweise `0755` für lokale
|
||||
Benutzer lesbar. Der Negativtest als `ghost-agent` bestätigte den Zugriff auf
|
||||
die Identitätsdatenbank.
|
||||
|
||||
Am 06.10.2026 wurden beide Elternverzeichnisse auf `0700`, die Datenbank- und
|
||||
Trace-Datei auf `0600` sowie ein systemd-Drop-in mit `UMask=0077` gesetzt.
|
||||
Nach dem Keycloak-Neustart waren Keycloak und SSH aktiv, Discovery antwortete
|
||||
lokal und öffentlich mit HTTP 200 und der öffentliche Adminpfad zeigte
|
||||
unverändert den bekannten Redirect. `ghost-agent` konnte das Datenverzeichnis
|
||||
weder traversieren noch die Datenbank lesen.
|
||||
|
||||
## 2. SSH-Zugänge
|
||||
|
||||
Die gemeinsame
|
||||
[LXC-SSH-Baseline](../../GNAI-011-SEC_LXC-SSH-Architecture.md) ist umgesetzt.
|
||||
|
||||
| Identität | Lokaler Alias | Rechte |
|
||||
| --- | --- | --- |
|
||||
| `ghost-agent` | `ghostnet-ct102-agent` | UID/GID 1000, gesperrtes Passwort, keine Zusatzgruppen und kein sudo |
|
||||
| `ghost` | `ghostnet-ct102-ghost` | UID/GID 1101, persönlicher passphrasegeschützter Schlüssel, Gruppe `sudo` und passwortgeschütztes sudo |
|
||||
|
||||
Die dedizierten Schlüssel werden nicht wiederverwendet. Private Schlüssel und
|
||||
die separate Known-Hosts-Datei liegen ausschließlich unter `~/.ssh/` auf der
|
||||
Admin-Workstation. Im Repository stehen nur die nicht geheimen Fingerprints:
|
||||
|
||||
| Schlüssel | Fingerprint |
|
||||
| --- | --- |
|
||||
| Agent | `SHA256:PXJ0yuEyVoaW2CI5IVax5DYjM8u26Qfyyx/pIrmIkx0` |
|
||||
| Persönlich | `SHA256:HQt9LezRlGxwH4hcteI6/FZJQ4JvZZkShhux5BIXlN4` |
|
||||
|
||||
Die aktive Datei `/etc/ssh/sshd_config.d/99-security.conf` setzt:
|
||||
|
||||
- ausschließlich Public-Key-Authentifizierung für `ghost-agent` und `ghost`;
|
||||
- Root-, Passwort- und Keyboard-Interactive-Login aus;
|
||||
- TCP-, Agent-, StreamLocal-, X11- und Tunnel-Forwarding aus;
|
||||
- `MaxAuthTries 3`, `LoginGraceTime 30` und `LogLevel VERBOSE`.
|
||||
|
||||
Vor der Änderung wurde `/etc/ssh/sshd_config` rootgeschützt unter
|
||||
`/var/backups/ghostnet/ssh/` gesichert. Syntaxprüfung und kontrollierter
|
||||
Dienstneustart waren erfolgreich; `ssh.service` und `ssh.socket` sind aktiv.
|
||||
|
||||
### 2.1 Bewusste UID-Abweichung
|
||||
|
||||
Beim ersten Anlegen von `ghost` mit der sonst üblichen UID 1001 wurde
|
||||
festgestellt, dass 464 Einträge der bestehenden Keycloak-Distribution unter
|
||||
`/opt/keycloak` bereits numerisch `1001:118` gehören. Beide IDs waren zuvor
|
||||
keinem lokalen Konto beziehungsweise keiner lokalen Gruppe zugeordnet. Das
|
||||
neue Konto wäre dadurch unbeabsichtigt Eigentümer von `kc.sh`,
|
||||
`keycloak.conf` und weiteren Programmdateien geworden.
|
||||
|
||||
Nach Abmeldung aller persönlichen Sitzungen wurde `ghost` deshalb auf die
|
||||
zuvor als frei geprüfte UID/GID 1101 verschoben und das Homeverzeichnis gezielt
|
||||
angepasst. Danach galt:
|
||||
|
||||
- keine Datei unter `/opt/keycloak` gehört UID 1101;
|
||||
- die 464 bestehenden Einträge bleiben unter der nicht zugeordneten UID 1001
|
||||
und GID 118;
|
||||
- `ghost` kann weder `kc.sh` noch `keycloak.conf` schreiben;
|
||||
- persönlicher Key-Login und passwortgeschütztes sudo funktionieren weiterhin;
|
||||
- Keycloak, `ssh.service` und `ssh.socket` bleiben aktiv.
|
||||
|
||||
Die historische Eigentümerschaft der Keycloak-Distribution wird nicht ohne
|
||||
Stop-Backup und gesonderte Betriebsprüfung normalisiert. Bei einer späteren
|
||||
Umstellung des derzeit als Root laufenden Dienstes auf ein dediziertes
|
||||
Dienstkonto sind Eigentümer und notwendige Schreibpfade gemeinsam zu klären.
|
||||
|
||||
## 3. Abnahme am 05.10.2026
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| --- | --- |
|
||||
| Hostschlüsselvergleich | bestanden |
|
||||
| Agenten-Key-Login | bestanden |
|
||||
| Persönlicher Key-Login mit Passphrase | bestanden |
|
||||
| Agent ohne Zusatzgruppen, Shadow-Leserecht und sudo | bestanden |
|
||||
| Persönliches passwortgeschütztes sudo | bestanden |
|
||||
| Keine Schreibrechte des Administratorkontos auf Keycloak-Programm und -Konfiguration | bestanden nach UID-Korrektur |
|
||||
| Root-Login mit gültigem Agentenschlüssel | abgelehnt |
|
||||
| Passwort-SSH nach Vergabe des lokalen Kontopassworts | abgelehnt |
|
||||
| Remote-Forwarding | abgelehnt |
|
||||
| SSH-Syntax und effektive Baseline | bestanden |
|
||||
| Vollständiger LXC-Wiederanlauf | bestanden nach Stop-Backup am 05.10.2026 |
|
||||
|
||||
Das vollständige Proxmox-Backup wurde im Modus `Stop` erstellt. Der dadurch
|
||||
ausgelöste LXC-Start erfolgte um 19:37 Uhr. Danach waren Keycloak,
|
||||
`ssh.service` und `ssh.socket` aktiv und aktiviert; fehlgeschlagene Units
|
||||
wurden nicht gemeldet. Unit- und Konfigurationsprüfsummen, die UID-Trennung
|
||||
sowie beide Listener blieben erhalten. Agentenlogin, persönlicher Key-Login,
|
||||
passwortgeschütztes sudo und die externen Negativtests wurden erneut
|
||||
erfolgreich geprüft.
|
||||
|
||||
## 4. Nächste Schritte
|
||||
|
||||
1. Eine unterstützte Produktionsdatenbank bereitstellen, Daten und
|
||||
Konfiguration kontrolliert migrieren und `start-dev` ersetzen.
|
||||
2. Keycloak nach Prüfung sämtlicher 26.x-Migrationshinweise und separater
|
||||
Datenbanksicherung auf die aktuelle sicherheitsgepflegte Version
|
||||
aktualisieren.
|
||||
3. Internen NPM-vHost mit gültigem TLS einrichten und Keycloak
|
||||
`hostname-admin` passend zur installierten Version setzen.
|
||||
4. Internen Adminzugang testen, anschließend öffentliche Admin-GUI und
|
||||
Admin-API sperren.
|
||||
5. Externe Negativtests sowie die produktive OIDC-Regression durchführen.
|
||||
6. Nach den Keycloak-Änderungen CT 102 kontrolliert neu starten und SSH,
|
||||
Keycloak, Proxy- und OIDC-Pfade
|
||||
erneut abnehmen.
|
||||
7. Historische Eigentümer `1001:118` und Root-Dienstbetrieb nach Backup
|
||||
bewerten; keine pauschale rekursive Eigentümeränderung vornehmen.
|
||||
|
||||
Die NPM-seitige Konfiguration und öffentliche Pfadsperre werden ergänzend in
|
||||
[GNAI-006](../ct-103-reverse-proxy/GNAI-006-SEC_Reverse-Proxy.md) geführt.
|
||||
+589
@@ -0,0 +1,589 @@
|
||||
# Keycloak – Blue/Green-Migration auf Podman und PostgreSQL
|
||||
|
||||
**Dokumenten-ID:** GNAI-015-PLAN
|
||||
**Bezug:** [CT 102 / Keycloak](GNAI-014-SEC_Keycloak.md)
|
||||
**Status:** Historische erste Blue/Green-Migration auf CT 104 abgeschlossen;
|
||||
anschließend durch den reproduzierbaren CT-102-Neuaufbau aus GNAI-016 abgelöst
|
||||
**Ziel:** Den nativen Keycloak 26.2.4 mit H2 und `start-dev` kontrolliert
|
||||
auf einen gepinnten Podman-/Quadlet-Stack mit PostgreSQL und aktuellem
|
||||
Keycloak migrieren, ohne den geprüften Rückfallweg über CT 102 vorzeitig
|
||||
aufzugeben.
|
||||
|
||||
## 1. Beschluss und Grenzen
|
||||
|
||||
Die Migration erfolgt **Blue/Green in einem neuen LXC**. Eine In-place-
|
||||
Containerisierung in CT 102 wird verworfen, weil sie Anwendung, neue
|
||||
Datenbank, Podman-Installation und Rückfallzustand auf demselben
|
||||
4-GiB-Dateisystem koppeln würde.
|
||||
|
||||
Verbindliches Ziel:
|
||||
|
||||
| Merkmal | Vorschlag |
|
||||
| --- | --- |
|
||||
| Ziel-LXC | CT 104, am 06.10.2026 als unprivilegierter Debian-13-LXC erstellt |
|
||||
| Hostname | `keycloak`; bewusste Betreiberentscheidung, Unterscheidung zum gleichnamigen CT 102 über CT-ID und IP |
|
||||
| IPv4 | `10.0.0.16/24`, vom Betreiber am 06.10.2026 als frei bestätigt |
|
||||
| Basis | unprivilegierter Debian-13-LXC |
|
||||
| Ressourcen | 2 vCPU, 4096 MiB RAM, 1024 MiB Swap, mindestens 12 GiB RootFS |
|
||||
| LXC-Features | `nesting=1,keyctl=1`; weitere Geräte/Funktionen nur nach nachgewiesenem Bedarf |
|
||||
| Laufzeit | rootful Podman mit systemd-Quadlets |
|
||||
| Podman-Netz | `10.89.0.0/24`, Gateway `10.89.0.1`, PostgreSQL `.10`, Keycloak `.20` |
|
||||
| Datenbank | gepinntes PostgreSQL 16 auf internem Podman-Netz, kein veröffentlichter DB-Port |
|
||||
| Keycloak | zuerst 26.2.4 für den Import, danach kontrolliert auf die aktuelle sicherheitsgepflegte 26.x-Version |
|
||||
| Öffentlicher Traffic | ausschließlich über NPM/CT 103 |
|
||||
| Rückfall | CT 102 bleibt ausgeschaltet beziehungsweise vom NPM-Datenpfad getrennt, aber unverändert wiederanlaufbar |
|
||||
|
||||
CT-ID und IP sind nach Betreiberprüfung verbindlich.
|
||||
Die temporäre Ziel-IP wird nach erfolgreichem Cutover beibehalten; NPM wird auf
|
||||
das neue Backend umgestellt. Das vermeidet einen IP-Tausch zwischen altem und
|
||||
neuem LXC und vereinfacht den Rückfall.
|
||||
|
||||
## 2. Zielarchitektur
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U[Öffentliche OIDC-Clients] --> PA[auth.ghostnet.selfhost.eu]
|
||||
A[LAN / Server-Admin-VPN] --> IA[auth-admin.ghostnet.selfhost.eu]
|
||||
PA --> N[NPM · CT 103]
|
||||
IA --> N
|
||||
N --> K[Keycloak · neuer Podman-LXC]
|
||||
K --> P[(PostgreSQL · internes Podman-Netz)]
|
||||
O[CT 102 · alter Keycloak/H2] -. nur Rückfall .-> N
|
||||
```
|
||||
|
||||
Sicherheitsgrenzen:
|
||||
|
||||
- PostgreSQL wird weder im LAN noch öffentlich veröffentlicht.
|
||||
- Keycloak-Port 8080 akzeptiert auf dem Ziel-LXC nur NPM/CT 103; SSH nur aus
|
||||
LAN und über den bereits kontrollierten WireGuard-Managementpfad.
|
||||
- Der Managementport 9000 wird nicht am LAN veröffentlicht.
|
||||
- NPM muss eingehende `X-Forwarded-*`-Header überschreiben. Keycloak vertraut
|
||||
Proxy-Headern nur im vorgesehenen NPM-Pfad.
|
||||
- Interner DNS ist kein Zugriffsschutz. Der Admin-vHost benötigt zusätzlich
|
||||
eine NPM-Quellnetzbeschränkung.
|
||||
- Secrets, Exporte, Datenbank-Dumps und Private Keys liegen nie im Repository.
|
||||
|
||||
## 3. Phasen und Checklisten
|
||||
|
||||
### Phase 0 – Bestehenden CT 102 stabilisieren
|
||||
|
||||
- [x] H2-Datenverzeichnisse auf `0700`, Dateien auf `0600` setzen und mit
|
||||
systemd-`UMask=0077` gegen erneute breite Dateirechte absichern.
|
||||
- [x] Negativtest: `ghost-agent` kann H2-Dateien weder lesen noch auflisten.
|
||||
- [x] Vollständiges Proxmox-Stop-Backup von CT 102 auf Storage `datenhdd2`
|
||||
vorhanden: `vzdump-lxc-102-2026_10_05-19_37_11.tar.zst`, Größe
|
||||
545.368.758 Byte; der Wiederanlauf wurde abgenommen. Da die H2-Datenbank am
|
||||
09.10.2026 erneut geändert wurde, dient dieses Archiv als bekannter
|
||||
Rückfallpunkt und wird vor dem Export durch ein frisches Stop-Backup des
|
||||
Migrationsstands ergänzt. Dieses neue Stop-Backup
|
||||
`vzdump-lxc-102-2026_10_09-17_45_58.tar.zst` auf `datenhdd2` wurde in 13
|
||||
Sekunden erfolgreich erstellt (545.589.232 Byte); CT, Keycloak und beide
|
||||
SSH-Units liefen anschließend wieder, ohne fehlgeschlagene Unit.
|
||||
- [x] SSH-, Keycloak- und OIDC-Wiederanlauf nach diesem Backup bestanden.
|
||||
- [x] In Proxmox Schutz für das aktuelle CT-102-Backup aktivieren,
|
||||
Archivintegrität prüfen und Speicherort/Archivname dokumentieren. Das
|
||||
Archiv vom 05.10.2026 und der Migrationsstand vom 09.10.2026 sind beide als
|
||||
geschützt bestätigt. Das aktuelle Archiv liegt unter
|
||||
`/media/datenhdd2/dump/vzdump-lxc-102-2026_10_09-17_45_58.tar.zst`, bestand
|
||||
`zstd --test` und hat SHA-256
|
||||
`576a10487fdc9fa6ab558861050405740f4c49d3888f80f215a1f7ff55f0cf42`.
|
||||
Sämtliche drei CT-102-Archive wurden zunächst von fehlerhaftem `0777` auf
|
||||
`root:root`, `0600` korrigiert. Am 11.10.2026 standen diese Archive und die
|
||||
Schutz-Sentinels jedoch erneut auf `0777`; damit liegt eine wiederkehrende
|
||||
Rechteänderung und kein reiner Altbestand vor. Der Btrfs-Mount enthält neben
|
||||
`dump/` auch einen bewusst gemeinsam genutzten Medienbestand unter
|
||||
`extern/`. Deshalb wird der Backup-Unterbaum separat gekapselt und die
|
||||
Ursache in PVE-/LXC-Mounts sowie Datei-Freigaben gesucht, bevor weitere
|
||||
sensitive Dateien erzeugt werden. Als unmittelbare Ursache wurden zwei
|
||||
Root-Cronjobs gefunden, die alle zehn Minuten rekursiv `root:root` und
|
||||
`0777` auf `/media/datenhdd2` anwenden. Zusätzlich binden CT 101 und CT 106
|
||||
den gesamten Datenträger schreibbar ein; CT 304 und CT 904 verwenden ihn
|
||||
read-only. Die Provenienzprüfung ergab: Der Root-Crontab wurde am 14.06.2024
|
||||
mit `crontab` aus einer temporären Datei installiert, gehört zu keinem
|
||||
Debian-/PVE-Paket und die Befehle kommen weder im installierten PVE-Code noch
|
||||
in der PVE-Konfiguration vor. Der offizielle `pvescheduler` läuft separat;
|
||||
`/etc/pve/jobs.cfg` und Cluster-Backupjobs sind nicht vorhanden. Die Jobs
|
||||
sind daher ein früher administrativ oder durch ein Drittanbieter-Skript
|
||||
eingerichteter Rechte-Workaround, kein Proxmox-Standard. Vor einer
|
||||
Ersetzung werden Privilegierungsstatus und tatsächlich benötigte Teilpfade
|
||||
der beiden schreibenden Container geprüft. CT 101 ist der laufende,
|
||||
unprivilegierte Samba-LXC; CT 106 ist der derzeit gestoppte,
|
||||
unprivilegierte Tdarr-LXC. Beide verwenden die Standard-ID-Abbildung ab
|
||||
Host-UID 100000 und binden bislang den vollständigen Datenträger read-write
|
||||
ein. Damit erklärt sich der historische Kompatibilitätszweck, nicht aber der
|
||||
sicherheitswidrige Zugriff auf PVE-Backups, Images und RootFS-Inhalte. Vor
|
||||
der Ablösung werden die tatsächlich exportierten Samba-Pfade erfasst. Die
|
||||
wirksame Samba-Konfiguration bestätigte anschließend, dass von
|
||||
`datenhdd2` ausschließlich `/media/datenhdd2/extern` als Freigabe `Extern`
|
||||
verwendet wird; `dump/`, `images/` und `private/` sind keine Samba-Shares.
|
||||
Zum Prüfzeitpunkt bestand keine aktive Sitzung auf `Extern`. Damit können
|
||||
die Bind-Mounts von CT 101 und CT 106 später auf diesen Teilpfad begrenzt
|
||||
werden. Vorher werden die vollständigen Read-only-Mounts von CT 304 und CT
|
||||
904 auf Privilegierungsstatus und benötigte Teilpfade geprüft. CT 304 ist
|
||||
der laufende privilegierte Jellyfin-LXC mit identischer Host-/Gast-UID-
|
||||
Abbildung; CT 904 ist dessen gestopptes privilegiertes Template. Deren
|
||||
Read-only-Mount verhindert Änderungen, aber nicht das Lesen rootgeschützter
|
||||
Hostdateien. Da außerhalb von `extern/` auf diesem Datenträger nur PVE-
|
||||
Inhalte liegen, werden auch diese beiden Mounts auf `extern/` begrenzt.
|
||||
- [x] Wiederkehrende Freigabe der PVE-Backups auf `0777` beseitigen und die
|
||||
LXC-Zugriffsgrenze auf den tatsächlich benötigten Medienpfad reduzieren.
|
||||
Am 11.10.2026 wurden zunächst die Konfigurationen von CT 101, CT 106,
|
||||
CT 304 und CT 904 sowie der Root-Crontab unter
|
||||
`/root/ghostnet-datenhdd2-hardening-20261011T203908/` gesichert. Der
|
||||
geprüfte Crontab-Diff entfernte ausschließlich die beiden rekursiven
|
||||
`chown`-/`chmod 0777`-Jobs für `/media/datenhdd2`; die vergleichbaren
|
||||
Altjobs anderer Datenträger blieben unverändert. Anschließend wurden die
|
||||
vier vollständigen Bind-Mounts durch
|
||||
`/media/datenhdd2/extern` ersetzt: CT 101 und CT 106 read-write, CT 304 und
|
||||
CT 904 weiterhin read-only. `dump/` steht nun auf `root:root`, `0700`, alle
|
||||
direkt enthaltenen Backup-, Log-, Notiz- und Schutzdateien auf `0600`;
|
||||
Storage-Wurzel, `images/` und `private/` stehen auf `0755`, während
|
||||
`extern/` zur Wahrung der bisherigen unprivilegierten LXC-Kompatibilität
|
||||
vorerst unverändert `0777` bleibt. Samba und Jellyfin starteten danach
|
||||
erfolgreich, CT 106 und das Template CT 904 blieben ausgeschaltet,
|
||||
Proxmox listet alle drei CT-102-Backups weiterhin und auf dem Host ist keine
|
||||
Unit fehlgeschlagen.
|
||||
- [x] Explizite Pfad-Isolationsprüfung in CT 101 und CT 304 abschließen. Die
|
||||
Prüfung um 20:53 Uhr und damit nach dem nächsten früheren Cron-Zeitpunkt
|
||||
bestätigte weiterhin `0755` für die Storage-Wurzel, `0700` für `dump/` und
|
||||
`0600` für alle direkt darin liegenden Dateien; der aktive Root-Crontab
|
||||
enthält keine Regel für `datenhdd2`. CT 101 sieht ausschließlich
|
||||
`/media/datenhdd2/extern` read-write, CT 304 denselben Teilpfad read-only;
|
||||
`dump/`, `images/` und `private/` sind in beiden Gästen nicht sichtbar.
|
||||
Beide Pfadprüfungen und die Host-Unitprüfung bestanden ohne Fehler.
|
||||
- [x] Praktischen Clienttest der Samba-Freigabe `Extern` und einer dort
|
||||
liegenden Jellyfin-Mediendatei abschließen. Der Betreiber bestätigte am
|
||||
11.10.2026 sowohl Erstellen und Löschen einer Testdatei über Samba als auch
|
||||
die Wiedergabe eines Mediums aus `datenhdd2/extern` über Jellyfin. Die
|
||||
Einschränkung der Bind-Mounts verursachte damit keine erkennbare
|
||||
Funktionsregression; der Keycloak-Export kann beginnen.
|
||||
|
||||
### Phase 1 – Ziel-LXC freigeben und aufbauen
|
||||
|
||||
- [x] Freie CT-ID 104 sowie `10.0.0.16` bei der erfolgreichen Erstellung
|
||||
bestätigt.
|
||||
- [x] Unprivilegierten Debian-13-LXC mit 12 GiB RootFS, zwei vCPU, 4096 MiB
|
||||
RAM, statischer IPv4, Autostart sowie `nesting=1,keyctl=1` erstellen.
|
||||
- [x] Swap von 512 auf 1024 MiB anheben und den anschließenden Wiederanlauf
|
||||
prüfen; Hostname `keycloak` und bestehende Community-Script-Tags bleiben auf
|
||||
Betreiberwunsch unverändert.
|
||||
- [x] Direkt danach ein Baseline-Backup im Modus `Stop` erstellen:
|
||||
`/media/datenhdd2/dump/vzdump-lxc-104-2026_10_06-14_49_53.tar.zst`;
|
||||
Abschluss erfolgreich, Archivgröße 426 MB, automatischer Wiederanlauf nach
|
||||
16 Sekunden.
|
||||
- [x] CT 104 ohne ungetesteten Post-install-Hook erstellen.
|
||||
- [x] Allgemeinen, secretfreien LXC-Baseline-Hook getrennt entwickeln und auf
|
||||
dem noch anwendungsfreien Ziel kontrolliert erproben.
|
||||
- [x] `ghost-agent` als UID/GID 1000 mit gesperrtem Passwort, ohne
|
||||
Zusatzgruppen oder sudo und mit dediziertem CT-104-Schlüssel einrichten;
|
||||
Fingerprint
|
||||
`SHA256:GCCeciGznhKQjb8xRWm6qbqKNdsuIJAQ1+cgNzcKPmM`, strikte
|
||||
Hostschlüsselprüfung und Alias `ghostnet-ct104-agent` praktisch abgenommen.
|
||||
- [x] `ghost` als UID/GID 1001 mit eigenem passphrasegeschütztem CT-104-
|
||||
Schlüssel und passwortgeschütztem sudo einrichten; Fingerprint
|
||||
`SHA256:4OgAGXgf6M/uQFnGdDTcZ+5qturtdWDTUHb5FCjdz+8` und Alias
|
||||
`ghostnet-ct104-ghost` praktisch abgenommen.
|
||||
- [x] Root-, Passwort- und sämtliche SSH-Forwarding-Zugriffe sperren und
|
||||
positiv wie negativ testen; beide Schlüsselzugänge, sudo, SSH-Units,
|
||||
effektive Konfiguration und Sperren nach LXC-Neustart erneut bestätigt.
|
||||
- [x] nftables mit INPUT- und FORWARD-DROP aktivieren: SSH nur aus
|
||||
`10.0.0.0/24`, veröffentlichter TCP-Port 8080 ausschließlich von CT 103;
|
||||
Laptop-Zugriff lief vor und nach LXC-Neustart in einen Timeout, während CT
|
||||
103 jeweils unmittelbar eine HTTP-Antwort erhielt.
|
||||
- [x] Podman installieren und Version 5.4.2 dokumentieren.
|
||||
- [x] Digest-gepinnten BusyBox-Testcontainer per Quadlet auf dem festen
|
||||
Podman-Netz `10.89.0.0/24` mit `10.89.0.20` starten; Aardvark-DNS,
|
||||
Netavark-DNAT, Quellfilterung, SSH-Erreichbarkeit und automatischen
|
||||
Wiederanlauf von nftables, Netzwerk und Container nach vollständigem
|
||||
LXC-Neustart am 06.10.2026 erfolgreich prüfen.
|
||||
- [x] Ziel-IP `10.0.0.16` in die native Server-Admin-Zielmenge von CT 105,
|
||||
wg-easy und das geschützte Laptop-/NetworkManager-Profil aufnehmen. LAN- und
|
||||
Fremdnetztests bestanden: Route über `ghost-laptop` mit Quelle `10.8.0.3`,
|
||||
Split-DNS, Ping und Agenten-SSH erfolgreich; CT 105 zählte FORWARD und
|
||||
natives MASQUERADE, CT 104 sah `10.0.0.15` als maskierte Quelle. TCP 8080
|
||||
blieb für den VPN-Admin gesperrt.
|
||||
|
||||
### Phase 2 – Gepinnten Podman-Stack bereitstellen
|
||||
|
||||
- [x] Offizielle Images für Keycloak 26.2.4 und PostgreSQL 16.15-bookworm auf
|
||||
konkrete amd64-Manifeste auflösen und in
|
||||
`proxmox/lxc/ct-104-keycloak/images.lock` dokumentieren: Keycloak
|
||||
`sha256:b79f7fd8a3fc9d56ebd274d4e0a5ba996253dedf7ad2cb23ccac83a690a10bc6`,
|
||||
PostgreSQL
|
||||
`sha256:ec9f656f17c8b4dfebf49e58cf5c46d493d4846d4ea6d8b85d0637f68ee33bfc`.
|
||||
- [x] Rootgeschützte Verzeichnisse für Quadlets, Export/Import, Secrets und
|
||||
Backups anlegen; Keycloak-Bind-Mounts besitzen numerisch `1000:0`, bleiben
|
||||
aber durch den nicht traversierbaren root-only Elternpfad für das
|
||||
gleichnummerierte Hostkonto `ghost-agent` unzugänglich. PostgreSQL-Daten
|
||||
werden über eine deklarative `.volume`-Quadlet mit `999:999` verwaltet.
|
||||
- [x] Zufälliges 256-Bit-DB-Passwort außerhalb des Repositorys unter
|
||||
`/etc/ghostnet/keycloak/secrets/postgres-password` als `root:root`, `0600`
|
||||
und ohne abschließende Ausgabe erzeugen; Podman-Secret
|
||||
`keycloak-db-password` mit Dateitreiber angelegt.
|
||||
- [x] Internes Podman-Netz `ghostnet-keycloak` mit `10.89.0.0/24` sowie das
|
||||
deklarative Volume `ghostnet-keycloak-postgres` erstellen; Volume leer,
|
||||
numerisch `999:999`, Modus `0700`, `nocopy`, gelabelt und über die
|
||||
zugehörige systemd-Unit reproduzierbar abgenommen.
|
||||
- [x] PostgreSQL-Quadlet mit Healthcheck, Ressourcenlimit, `no-new-privileges`
|
||||
soweit kompatibel und ohne Host-Port veröffentlichen. Quadlet erzeugt und
|
||||
statisch validiert; der zuerst verwendete `Tmpfs=`-Ansatz wurde nach einem
|
||||
kontrollierten Fehler ohne Dateninitialisierung durch das unter Podman 5.4
|
||||
verifizierte `Mount=type=tmpfs,...,tmpfs-mode=3775,U=true` ersetzt. Der erste
|
||||
echte Start erfolgt mit `Restart=no`, um Fehlerschleifen auszuschließen. Der
|
||||
erste Initialisierungsversuch zeigte anschließend den erwartbaren Konflikt
|
||||
zwischen OS-Nutzer `postgres`, DB-Nutzer `keycloak` und
|
||||
`--auth-local=peer`; das noch datenfreie Teil-Cluster wird verworfen und mit
|
||||
explizitem lokalem sowie netzseitigem `scram-sha-256` neu initialisiert. Die
|
||||
Neuinitialisierung gelang, aber `Notify=healthy` lieferte auf Podman 5.4 bis
|
||||
zum systemd-Timeout kein Signal. Der bestehende Cluster wird daher einmal
|
||||
mit `Notify=false` gestartet, um Healthstatus und Healthcheck getrennt von
|
||||
sd-notify zu prüfen; dabei wurde der falsche Pfad
|
||||
`/usr/local/bin/pg_isready` als alleinige Healthcheck-Ursache erkannt und
|
||||
durch die Auflösung von `pg_isready` über den Image-`PATH` ersetzt. Der
|
||||
korrigierte Check erreichte manuell `healthy`; die erneute Abnahme mit
|
||||
`Notify=healthy` gelang in 0,462 Sekunden. Authentifizierte Abfrage bestätigte
|
||||
Datenbank und Benutzer `keycloak`, PostgreSQL 16.15 und
|
||||
`password_encryption=scram-sha-256`; alle HBA-Regeln verwenden SCRAM, und es
|
||||
besteht keine Host-Portfreigabe. Der abschließende harte Abbruch führte nach
|
||||
`RestartSec=5s` zu einer neuen Container-ID, automatischer WAL-Recovery,
|
||||
erneutem `healthy`, erfolgreicher authentifizierter Abfrage und
|
||||
`NRestarts=1`; keine Unit blieb fehlgeschlagen. Vor dem ersten Keycloak-
|
||||
Schema wurde erkannt, dass das offizielle Image `POSTGRES_USER=keycloak` als
|
||||
Cluster-Superuser erzeugt hat. Offen ist daher noch: separates
|
||||
rootgeschütztes DB-Administratorkonto erstellen und testen, anschließend die
|
||||
Anwendungsrolle `keycloak` transaktional auf `NOSUPERUSER`, `NOCREATEDB`,
|
||||
`NOCREATEROLE` und `NOREPLICATION` reduzieren. Damit diese Trennung auch bei
|
||||
einem Neuaufbau erhalten bleibt, wird das noch schemafreie Volume stattdessen
|
||||
sauber neu initialisiert: `ghostnet_postgres_admin` ist Initial-Superuser;
|
||||
das secret-freie Init-Skript `10-create-keycloak-role.sh` erzeugt die
|
||||
eingeschränkte Anwendungsrolle und überträgt ihr Datenbank sowie Schema. Die
|
||||
abschließende Abnahme bestätigte den Administrator als einzigen Superuser
|
||||
und die Anwendungsrolle mit Login, aber ohne Superuser-, Create-DB-,
|
||||
Create-Role-, Replikations- oder Bypass-RLS-Recht. Datenbank und `public`-
|
||||
Schema gehören `keycloak`; Verbindungs-, temporäre und Schema-Nutzung sowie
|
||||
ein vollständig zurückgerollter transaktionaler Tabellen-Test waren
|
||||
erfolgreich. Der Commissioning-Drop-in wurde entfernt,
|
||||
`Restart=on-failure` ist wieder wirksam und keine Unit ist fehlgeschlagen.
|
||||
- [ ] Keycloak-Quadlet zunächst mit Image 26.2.4, Ressourcenlimit und Port
|
||||
8080 bereitstellen; kein Bootstrap-Standardpasswort verwenden. Das
|
||||
secret-freie `Containerfile.keycloak` ist aus dem gepinnten 26.2.4-amd64-
|
||||
Manifest für PostgreSQL, Health und Metrics optimiert. Der erste Build
|
||||
erreichte den eigentlichen `kc.sh build`-Schritt nicht,
|
||||
weil Podmans Standard-Buildnetz `pasta` im LXC ein nicht vorhandenes
|
||||
`/dev/net/tun` verlangte; der netzunabhängige Wiederholungsbuild verwendet
|
||||
deshalb ausdrücklich `--network=none`. Dieser Build war erfolgreich und ist
|
||||
als lokale Image-ID
|
||||
`sha256:7bedb7b2f99a31829d7630afefe8e55ab89b2b41322a22ec006f550147d9bc03`
|
||||
zusammen mit der Containerfile-Prüfsumme in `images.lock` festgehalten.
|
||||
Die vorbereitete Anwendungs-Quadlet referenziert ausschließlich diese lokale
|
||||
Image-ID, besitzt noch keinen Autostart und verlangt zusätzlich die bewusst
|
||||
noch nicht angelegte Markerdatei
|
||||
`/etc/ghostnet/keycloak/allow-keycloak-start`. Dadurch können Generierung,
|
||||
Abhängigkeiten, Portbindung, Healthcheck und Sicherheitsparameter geprüft
|
||||
werden, ohne vor dem kontrollierten Export/Import ein Keycloak-Schema zu
|
||||
erzeugen. Die statische Abnahme auf CT 104 war erfolgreich: systemd lud die
|
||||
generierte Unit ohne Fehler, Image-ID, feste Container-IP, ausschließlich
|
||||
an `10.0.0.16:8080` gebundener Host-Port, interner Managementport,
|
||||
Read-only-Root, Capability-Drop, UID/GID `1000:0`, Healthcheck und
|
||||
`Restart=no` erschienen wie vorgesehen im erzeugten `podman run`-Aufruf.
|
||||
Die Unit blieb `inactive` im Zustand `generated`; es entstanden weder ein
|
||||
Keycloak-Container noch Listener auf 8080/9000, PostgreSQL blieb gesund und
|
||||
keine Unit war fehlgeschlagen.
|
||||
- [ ] `start` statt `start-dev`, PostgreSQL als DB sowie
|
||||
`http-enabled=true` für die TLS-Terminierung am NPM konfigurieren.
|
||||
- [ ] Health/Metrics nur intern im Container-/Managementpfad nutzen.
|
||||
- [ ] Quadlet-Syntax, Image-Digests, Volumes, Dateirechte und Autostart
|
||||
prüfen, bevor Daten importiert werden.
|
||||
|
||||
### Phase 3 – Konsistenter Export aus CT 102
|
||||
|
||||
- [x] Read-only-Preflight am 09.10.2026: Keycloak 26.2.4 auf OpenJDK 21,
|
||||
aktiver Root-Dienst mit `kc.sh start-dev`, Datenbank `dev-file`, Listener
|
||||
TCP 8080, H2-Verzeichnis `0700`, Datenbankdateien `0600`, 2,5 GiB freier
|
||||
Speicher und keine fehlgeschlagene Unit. Letzte Änderung der H2-Hauptdatei:
|
||||
09.10.2026 17:20:18 CEST.
|
||||
- [x] Wartungsfenster am 12.10.2026 festlegen; während Export und Cutover keine
|
||||
administrativen Änderungen zulassen.
|
||||
- [x] Keycloak auf CT 102 stoppen. Die alte systemd-Unit wertete den normalen
|
||||
Java-SIGTERM-Abschluss zunächst als Status 143/Fehler; das Keycloak-Journal
|
||||
bestätigte jedoch das Beenden der Transaktionsverwaltung und einen sauberen
|
||||
Shutdown in 51 ms. Es blieben weder Prozess noch Listener zurück; der
|
||||
systemd-Fehlerzustand wurde anschließend zurückgesetzt.
|
||||
- [x] H2-Dateien, `conf/`, Provider, Themes, systemd-Unit und Drop-ins
|
||||
rootgeschützt in
|
||||
`/var/backups/ghostnet/keycloak-migration/20261012T014251/keycloak-offline-snapshot.tar`
|
||||
sichern. Das Archiv ist `root:root`, `0600`, 1.546.240 Byte groß und enthält
|
||||
beide H2-Dateien sowie alle vorgesehenen Konfigurationspfade; SHA-256:
|
||||
`4c4bc17ffa44b56ae453bf4d4daacb6a0a1eefb70b776fcae6644da8bd95c98d`.
|
||||
- [x] Mit Keycloak 26.2.4 einen Offline-CLI-Export aller Realms und Benutzer in
|
||||
das rootgeschützte Verzeichnis `export/` durchführen; Strategie
|
||||
`different_files`, 100 Benutzer je Datei. Keycloak meldete den erfolgreichen
|
||||
Vollmodell-Export der Realms `master`, `outline` und `Ghost.net` sowie ihrer
|
||||
Benutzerdateien. Alle sechs JSON-Dateien gehören `root:root` und stehen auf
|
||||
`0600`.
|
||||
- [x] Exportabschluss, JSON-Lesbarkeit, Dateinamenskonventionen und
|
||||
Realm-/Benutzerdateien prüfen, ohne Secrets zu protokollieren. Alle sechs
|
||||
Dateien bestanden `python3 -m json.tool`; das aus ihnen und dem
|
||||
Prüfsummenmanifest erzeugte rootgeschützte Transportarchiv
|
||||
`keycloak-export-26.2.4-20261012T015503.tar` ist 266.240 Byte groß,
|
||||
`tar --compare` meldete keine Abweichung und SHA-256 ist
|
||||
`4218d20dd98d5fc24bb5ecaeedb31dadb8603cc86516ababe6e31a63d2caae8a`.
|
||||
- [x] Export verschlüsselt beziehungsweise nur rootlesbar zum Ziel-LXC
|
||||
übertragen. Das Archiv wurde lokal mit `pct pull` in ein `0700`-
|
||||
Verzeichnis des Proxmox-Hosts übernommen, dort gegen den erwarteten
|
||||
SHA-256 geprüft und mit `pct push` nach
|
||||
`/var/backups/ghostnet/keycloak/` auf CT 104 übertragen. Quelle,
|
||||
Hostkopie und Zielarchiv besitzen identischen SHA-256
|
||||
`4218d20dd98d5fc24bb5ecaeedb31dadb8603cc86516ababe6e31a63d2caae8a`;
|
||||
das Ziel ist `root:root`, `0600`, 266.240 Byte. PostgreSQL blieb aktiv,
|
||||
Keycloak inaktiv, das Startgate geschlossen und keine Unit fehlgeschlagen.
|
||||
- [x] Alten Keycloak wieder starten und öffentliche OIDC-Funktion prüfen,
|
||||
solange der neue Stack noch nicht produktiv ist. Der Wiederanlauf, lokale
|
||||
Master-Realm-Discovery, Listener 8080 und fehlerfreie systemd-Lage sind
|
||||
bestätigt. Der öffentliche NPM-Pfad lieferte anschließend vom Admin-PC für
|
||||
die Discovery des Realms `Ghost.net` HTTP 200 über die öffentliche Adresse.
|
||||
Der Exportlauf aktualisierte wie von Keycloak angekündigt die lokal
|
||||
persistierte Serverkonfiguration vor dem Export; der anschließende
|
||||
erfolgreiche Wiederanlauf belegt deren grundsätzliche Lauffähigkeit.
|
||||
|
||||
Der CLI-Export ist kein vollständiges Datenbank-Backup: Sitzungen, Events,
|
||||
Workflow-Zustand und widerrufene Tokens werden nicht übertragen. Nutzer müssen
|
||||
sich nach dem Cutover neu anmelden.
|
||||
|
||||
### Phase 4 – Import auf gleicher Keycloak-Version
|
||||
|
||||
- [x] PostgreSQL 16 auf dem Ziel-LXC starten und Bereitschaft prüfen. Nach dem
|
||||
Transfer und der Bereitstellung der sechs checksum-validierten JSON-Dateien
|
||||
als numerische UID/GID `1000:0`, Modus `0600`, blieb der PostgreSQL-
|
||||
Container `running/healthy`. Keycloak und seine Ports blieben inaktiv, das
|
||||
Startgate geschlossen und keine Unit war fehlgeschlagen.
|
||||
- [x] Unmittelbar vor dem ersten Schemaimport ein Stop-Mode-Backup von CT 104
|
||||
erstellen. Der Lauf vom 12.10.2026 um 02:21 Uhr erzeugte
|
||||
`datenhdd2:backup/vzdump-lxc-104-2026_10_12-02_21_43.tar.zst`
|
||||
(1.138.072.621 Byte). Nach dem automatischen Neustart waren nftables,
|
||||
Podman-Netz, PostgreSQL-Volume und PostgreSQL aktiv; PostgreSQL war
|
||||
`running/healthy`. Die sechs Importdateien blieben erhalten, das
|
||||
Keycloak-Startgate geschlossen, Keycloak inaktiv, die Ports 8080/9000 frei
|
||||
und keine systemd-Unit fehlgeschlagen. Das Backup ist damit der
|
||||
Rückfallpunkt für den vollständig vorbereiteten, aber noch schemafreien
|
||||
Podman-/PostgreSQL-Zustand und ergänzt das frühe Baseline-Backup vom
|
||||
06.10.2026. `zstd --test` bestätigte 2.648.248.320 unkomprimierte Byte; die
|
||||
SHA-256-Prüfsumme des 1.138.072.621 Byte großen Archivs lautet
|
||||
`8b69a4666f8d0c28a8579b0873d7825f1a0cb4ecc46ac163d8f0d0ea7262ae9e`.
|
||||
Archiv, Log, Notiz und Schutzmarker sind `root:root` mit Modus `0600`; die
|
||||
Proxmox Storage API meldet für das Backup `protected: 1`.
|
||||
- [x] Keycloak 26.2.4 als einmaligen Importprozess gegen die neue
|
||||
PostgreSQL-Datenbank ausführen; produktiven Dienst währenddessen nicht
|
||||
parallel starten. Der gehärtete, nicht portveröffentlichte Einmal-Container
|
||||
`ghostnet-keycloak-import` lief am 12.10.2026 mit dem lokal gepinnten Image
|
||||
`sha256:7bedb7b2f99a31829d7630afefe8e55ab89b2b41322a22ec006f550147d9bc03`
|
||||
und endete mit Rückgabecode `0`. Keycloak initialisierte das PostgreSQL-
|
||||
Schema, importierte `master`, `outline` und `Ghost.net` einschließlich aller
|
||||
drei Benutzerdateien mit Strategie `OVERWRITE_EXISTING` und meldete
|
||||
`KC-SERVICES0032: Import finished successfully`. Das rootgeschützte Protokoll
|
||||
liegt unter
|
||||
`/var/backups/ghostnet/keycloak/keycloak-import-20261012T023757.log`.
|
||||
- [x] Importprotokoll auf Fehler prüfen und Importdateien anschließend vom
|
||||
laufenden Container trennen. Das Protokoll hat SHA-256
|
||||
`922559bf391c55686f922d00965f1c8ddab5edf1761162d8bae2ffc85a337515`
|
||||
und enthält einen vollständigen erfolgreichen Import ohne Fehler. Der mit
|
||||
`--rm` gestartete Importcontainer ist nicht mehr vorhanden; der reguläre
|
||||
Keycloak-Container blieb inaktiv und bindet das separate Importverzeichnis
|
||||
nicht ein. Eine unabhängige SQL-Abnahme bestätigte 88 relevante öffentliche
|
||||
Relationen beziehungsweise 284 öffentliche Objekte ausschließlich im
|
||||
Eigentum von `keycloak`, 152 Liquibase-Einträge sowie die Realms `Ghost.net`,
|
||||
`master` und `outline`. Die Benutzerzahlen sind 3, 1 und 1, die Clientzahlen
|
||||
9, 8 und 7. Datenbank und `public`-Schema gehören `keycloak`; die Rolle ist
|
||||
weder Superuser noch zu Create-DB, Create-Role, Replikation oder Bypass-RLS
|
||||
berechtigt. PostgreSQL blieb `running/healthy`, das Startgate geschlossen,
|
||||
die Ports 8080/9000 frei und keine systemd-Unit fehlgeschlagen.
|
||||
- [x] Keycloak 26.2.4 im Produktionsmodus starten. Zwei kontrollierte
|
||||
Erstversuche legten Besonderheiten der verschachtelten LXC-/Podman-
|
||||
Umgebung offen, ohne den Import oder PostgreSQL zu beschädigen:
|
||||
`sdnotify=healthy` lieferte trotz betriebsbereitem Keycloak kein READY an
|
||||
systemd; anschließend wurde eine verschachtelte Inline-`HealthCmd` beim
|
||||
Quadlet→systemd→Podman-Übergang syntaktisch beschädigt. Die endgültige,
|
||||
reproduzierbare Lösung verwendet `Notify=conmon`, den root-eigenen
|
||||
`ExecStartPost`-Helper `ghostnet-wait-container-healthy` und das separat
|
||||
read-only eingebundene Skript `ghostnet-keycloak-healthcheck`. Der erste
|
||||
erfolgreiche Produktionsstart am 12.10.2026 dauerte 20,997 Sekunden:
|
||||
Keycloak meldete nach 10,155 Sekunden den Prod-Start auf den internen Ports
|
||||
8080/9000, Podman wechselte anschließend von `starting` auf `healthy`, und
|
||||
erst danach wurde die Unit `active`. Der Container läuft als `1000:0` auf
|
||||
`10.89.0.20`; veröffentlicht ist ausschließlich `10.0.0.16:8080`, der
|
||||
Managementport 9000 bleibt intern und keine systemd-Unit ist fehlgeschlagen.
|
||||
- [x] Den neuen Backendpfad vor jeder NPM-Umschaltung prüfen. Die Discovery-
|
||||
Endpunkte von `master`, `outline` und `Ghost.net` antworteten direkt auf CT
|
||||
104 mit HTTP 200; der exportierte Issuer blieb exakt
|
||||
`https://auth.ghostnet.selfhost.eu/realms/Ghost.net`. Auch das über
|
||||
`auth-admin.ghostnet.selfhost.eu` adressierte Admin-Frontend lieferte HTTP
|
||||
200. Ein separater Aufruf aus CT 103 erreichte `10.0.0.16:8080` in 7 ms mit
|
||||
HTTP 200. Der nftables-Forward-Zähler der auf `10.0.0.10` beschränkten
|
||||
NPM→`10.89.0.20:8080`-Regel erhöhte sich dabei, während Port 9000 auf dem
|
||||
LXC-Host unveröffentlicht blieb.
|
||||
- [ ] Realms, Benutzer, Clients, Rollen, Authentifizierungsabläufe,
|
||||
Redirect-URIs, Schlüssel und Identity Provider gegen den Altstand
|
||||
vergleichen.
|
||||
- [ ] Adminlogin und ein echter Vaultwarden-OIDC-Login über einen temporären,
|
||||
nur intern erreichbaren NPM-Testhost prüfen.
|
||||
- [x] Erst nach erfolgreicher Abnahme einen PostgreSQL-`pg_dump` als
|
||||
Importmeilenstein erstellen. Der als eingeschränkte Rolle `keycloak`
|
||||
erzeugte PostgreSQL-16-Custom-Dump
|
||||
`/var/backups/ghostnet/keycloak/keycloak-post-import-26.2.4-20261012T024344.dump`
|
||||
ist 232.632 Byte groß und hat SHA-256
|
||||
`c1acabde36e0124ce6ce04734117216272d7b9e8b54a2b19879f9148b9ffae1d`.
|
||||
`pg_restore --list` endete mit Rückgabecode `0` und weist 88 `TABLE DATA`-
|
||||
Einträge aus; das 31.071 Byte große, ebenfalls rootgeschützte
|
||||
Inhaltsverzeichnis hat SHA-256
|
||||
`e0649efb2098ab4ac74bfde008574714c9385b15ccee934658bd3ee26a771448`.
|
||||
Der Dump verwendet bewusst `--no-owner --no-acl`; Rollen, Datenbank- und
|
||||
Schemarechte werden beim reproduzierbaren Neuaufbau deklarativ angelegt.
|
||||
|
||||
### Phase 5 – Upgrade auf aktuelle Keycloak-Version
|
||||
|
||||
- [ ] Zielversion unmittelbar vor Umsetzung gegen die offizielle
|
||||
Keycloak-Downloadseite bestätigen; Planungsstand ist 26.7.5.
|
||||
- [ ] Sämtliche Migrationshinweise zwischen 26.2.4 und der Zielversion
|
||||
prüfen, insbesondere OIDC, Admin-API, Features, Cache und Provider.
|
||||
- [ ] PostgreSQL-Dump, Volume-Metadaten, Quadlets, Environment und aktuelles
|
||||
Image-Pin rootgeschützt sichern.
|
||||
- [ ] Keycloak stoppen, Image-Pin auf die neue Version setzen und genau eine
|
||||
Instanz für die automatische Schematransformation starten.
|
||||
- [ ] Start- und Migrationsjournal prüfen; bei Fehlern den neuen Stack stoppen
|
||||
und **nicht** mit dem alten Image gegen die bereits migrierte Datenbank
|
||||
starten.
|
||||
- [ ] Discovery, JWKS, Tokenausgabe/-validierung, Adminlogin,
|
||||
Vaultwarden-OIDC und Neustart testen.
|
||||
- [ ] Nach bestandener Versionserhöhung einen neuen PostgreSQL-Dump erstellen.
|
||||
|
||||
### Phase 6 – Öffentlichen und internen Hostnamen trennen
|
||||
|
||||
- [x] Veraltetes `proxy=edge` entfernen; `proxy-headers=xforwarded` und
|
||||
`http-enabled=true` gezielt beibehalten.
|
||||
- [x] Frontend als vollständige URL
|
||||
`https://auth.ghostnet.selfhost.eu` konfigurieren.
|
||||
- [x] Adminfrontend als vollständige URL
|
||||
`https://auth-admin.ghostnet.selfhost.eu` konfigurieren.
|
||||
- [x] Internen NPM-vHost mit gültigem Zertifikat, Backend zum neuen LXC und
|
||||
Quellnetzbeschränkung für LAN sowie den von CT 105 maskierten
|
||||
Server-Admin-Verkehr erstellen. NPM-Proxy-Host 13 verwendet das gültige
|
||||
Let's-Encrypt-ECDSA-Zertifikat `npm-19`, leitet ausschließlich an
|
||||
`10.0.0.16:8080` weiter und erlaubt `10.0.0.0/24` vor einem abschließenden
|
||||
`deny all`. Direkte Tests aus dem LAN (`10.0.0.2`) und von CT 105
|
||||
(`10.0.0.15`) lieferten HTTP 200; der gleiche HTTPS-Aufruf aus einem
|
||||
externen Netz wurde mit HTTP 403 abgewiesen.
|
||||
- [ ] Internen Adminlogin zuerst im LAN, danach im fremden Netz über
|
||||
`ghost-laptop` testen.
|
||||
- [x] Am öffentlichen NPM-vHost `/admin`, `/admin/` und darunterliegende
|
||||
Admin-REST-/GUI-Pfade für HTTP und HTTPS sperren.
|
||||
- [x] Extern ohne VPN 403 beziehungsweise eine andere definitive Sperre
|
||||
bestätigen; Redirect auf Login ist kein Negativtest.
|
||||
- [ ] Master-Realm-Pfade erst nach dokumentierter Redirect- und
|
||||
OIDC-Abhängigkeit enger sperren.
|
||||
|
||||
`hostname-admin` ändert erzeugte Admin-URLs, sperrt die Admin-REST-API am
|
||||
öffentlichen Frontend aber nicht. Die NPM-Pfadsperre bleibt daher zwingend.
|
||||
|
||||
### Phase 7 – Produktiver Cutover
|
||||
|
||||
- [x] Vor Cutover einen letzten Offline-Export oder ein dokumentiertes
|
||||
Änderungsfreeze seit Phase 3 sicherstellen. Der Betreiber bestätigte am
|
||||
12.10.2026 ausdrücklich, dass seit dem konsistenten Offline-Export keine
|
||||
Benutzer-, Passwort-, Rollen-, Client-, Realm- oder sonstigen
|
||||
administrativen Keycloak-Änderungen vorgenommen wurden. Normale Sitzungen
|
||||
und Loginzustände dürfen wie geplant verfallen.
|
||||
- [x] Alten Keycloak auf CT 102 stoppen, damit nie zwei beschreibbare
|
||||
Identitätsquellen parallel produktiv sind. Nach bestätigt unverändertem
|
||||
Datenstand wurde der alte Dienst gestoppt und CT 102 anschließend vollständig
|
||||
heruntergefahren.
|
||||
- [x] Öffentlichen NPM-Host auf das neue Backend umstellen; generierte
|
||||
Nginx-Konfiguration und `nginx -t` prüfen. Proxy-Host 7 zeigt nun statt auf
|
||||
`10.0.0.4:8080` auf `10.0.0.16:8080`; die generierte Konfiguration enthält
|
||||
zusätzlich definitive HTTP-403-Locations für `/admin` und `/admin/`.
|
||||
`nginx -t` war erfolgreich. Die Discovery-Endpunkte aller drei Realms
|
||||
antworteten über NPM mit HTTP 200, der Login-Status-Origin-Test wechselte
|
||||
gegenüber CT 102 von HTTP 403 auf den erwarteten HTTP 204, der öffentliche
|
||||
Adminpfad lieferte HTTP 403 und der interne Adminpfad HTTP 200.
|
||||
- [ ] Öffentliche Discovery, JWKS, Login, Tokenaustausch und Logout testen.
|
||||
Die öffentliche Discovery wurde nach dem Cutover von einem externen Client
|
||||
mit HTTP 200 über die öffentliche Adresse bestätigt. Reale Anmeldung und
|
||||
Tokenaustausch sind durch den erfolgreichen Bitwarden-OIDC-Login belegt;
|
||||
JWKS und ein vollständiger Logout-/Neuanmeldezyklus bleiben noch explizit
|
||||
abzunehmen.
|
||||
- [x] Vaultwarden-/Bitwarden-OIDC mit echter Anmeldung und Tokenaustausch
|
||||
abnehmen. Nach dem Cutover gelang die reale Anmeldung über Keycloak
|
||||
einschließlich OTP erfolgreich.
|
||||
- [ ] Internen Adminzugang über LAN und VPN sowie externe Adminsperre erneut
|
||||
testen. Die technischen LAN- und CT-105-Tests lieferten HTTP 200; extern
|
||||
wurden sowohl der öffentliche `/admin`-Pfad als auch der interne Adminname
|
||||
von einem externen Client erneut mit HTTP 403 abgewiesen. Der echte
|
||||
Browser-Adminlogin aus LAN beziehungsweise Server-Admin-VPN bleibt als
|
||||
letzter Funktionstest offen.
|
||||
- [ ] DNS, NPM, neuer LXC, PostgreSQL, Keycloak und CT-105-Regelzähler
|
||||
dokumentieren.
|
||||
- [ ] Neuen LXC kontrolliert neu starten und vollständigen Wiederanlauf
|
||||
abnehmen.
|
||||
|
||||
### Phase 8 – Stabilisierungszeit und Stilllegung
|
||||
|
||||
- [ ] CT 102 ausgeschaltet, geschützt und unverändert mindestens 14 Tage als
|
||||
Rückfallstand behalten. Der Altcontainer ist heruntergefahren und mit
|
||||
`onboot: 0` sowie `protection: 1` gegen unbeabsichtigten Start und Löschung
|
||||
abgesichert; die 14-tägige Haltefrist läuft ab dem Cutover vom 12.10.2026.
|
||||
- [ ] Währenddessen Logs, fehlgeschlagene OIDC-Flows, Datenbankwachstum,
|
||||
Backups und Neustarts beobachten.
|
||||
- [ ] Hostunabhängigen PostgreSQL-Dump verschlüsselt sichern und mindestens
|
||||
einen Restore in eine leere Testdatenbank durchführen.
|
||||
- [ ] Nach Betreiberfreigabe CT 102 archivieren oder entfernen; vorher
|
||||
Rückfallentscheidung, Backupaufbewahrung und Secret-Widerruf dokumentieren.
|
||||
- [ ] Dokumentation und Inventar auf endgültige CT-ID/IP aktualisieren.
|
||||
- [ ] Nach der Löschfreigabe CT-ID 102 nicht dauerhaft freilassen, sondern die
|
||||
getrennt geplante
|
||||
[Ansible-basierte Rekonstruktion und zweite Blue/Green-Migration](GNAI-016-PLAN_Keycloak-Ansible-Rebuild.md)
|
||||
beginnen. CT 104 bleibt bis zu deren Abschluss die produktive Referenz und
|
||||
der Rückfallweg.
|
||||
|
||||
## 4. Abnahmematrix
|
||||
|
||||
| Bereich | Muss-Nachweis |
|
||||
| --- | --- |
|
||||
| Datenübernahme | alle erwarteten Realms, Benutzer, Clients, Rollen und Flows vorhanden |
|
||||
| Benutzerfunktion | mindestens ein realer Benutzerlogin und Vaultwarden-OIDC erfolgreich |
|
||||
| Issuer | öffentliche Discovery nennt unverändert den öffentlichen HTTPS-Issuer |
|
||||
| Admin intern | Admin-GUI und benötigte Admin-API über internen Namen in LAN und Server-Admin-VPN nutzbar |
|
||||
| Admin extern | GUI und Admin-API über öffentlichen Namen per HTTP/HTTPS gesperrt |
|
||||
| Netzwerk | PostgreSQL nicht veröffentlicht; Keycloak-Backend nur von CT 103 erreichbar |
|
||||
| Secrets | keine Schlüssel, DB-Passwörter, Exporte oder Dumps im Repository oder in Toolausgaben |
|
||||
| Persistenz | Quadlets, PostgreSQL, Keycloak, nftables und SSH über vollständigen LXC-Neustart stabil |
|
||||
| Recovery | PostgreSQL-Restore getestet; NPM-Rückschaltung und alter CT als zeitlich begrenzter Rückfall dokumentiert |
|
||||
|
||||
## 5. Rückfallverfahren
|
||||
|
||||
Vor dem Datenbank-Upgrade kann der neue Stack gestoppt und CT 102 wieder über
|
||||
NPM aktiviert werden. Nach einer Schemaänderung wird niemals das ältere Image
|
||||
gegen die neue PostgreSQL-Datenbank gestartet.
|
||||
|
||||
Rückfall nach dem produktiven Cutover:
|
||||
|
||||
1. Schreibzugriffe beziehungsweise Loginfenster kurz sperren.
|
||||
2. NPM vom neuen Backend trennen.
|
||||
3. Neuen Keycloak stoppen.
|
||||
4. CT 102 starten und dessen Keycloak-/SSH-/OIDC-Zustand prüfen.
|
||||
5. NPM auf `10.0.0.4:8080` zurückstellen.
|
||||
6. Discovery, Vaultwarden-OIDC und Adminstatus prüfen.
|
||||
7. Änderungen, mögliche im neuen System entstandene Benutzer-/Clientdaten und
|
||||
die weitere Migrationsentscheidung dokumentieren.
|
||||
|
||||
Der Rückfall verliert Änderungen, die ausschließlich nach dem Cutover in der
|
||||
neuen Datenbank entstanden sind. Deshalb gilt während der ersten
|
||||
Stabilisierungsphase ein administratives Änderungsfreeze.
|
||||
|
||||
## 6. Quellen
|
||||
|
||||
- [Keycloak: Import und Export](https://www.keycloak.org/server/importExport)
|
||||
- [Keycloak: Containerbetrieb](https://www.keycloak.org/server/containers)
|
||||
- [Keycloak: Produktionskonfiguration](https://www.keycloak.org/server/configuration-production)
|
||||
- [Keycloak: Hostname v2](https://www.keycloak.org/server/hostname)
|
||||
- [Keycloak: Reverse Proxy](https://www.keycloak.org/server/reverseproxy)
|
||||
- [Keycloak: Upgrade](https://www.keycloak.org/docs/latest/upgrading/)
|
||||
- [Keycloak: Security Policy](https://www.keycloak.org/security)
|
||||
+505
@@ -0,0 +1,505 @@
|
||||
# Keycloak – reproduzierbarer Neuaufbau von CT 102 mit Ansible
|
||||
|
||||
**Dokumenten-ID:** GNAI-016-PLAN
|
||||
**Vorgänger:** [GNAI-015 – Blue/Green-Migration auf CT 104](GNAI-015-PLAN_Keycloak-Podman-Migration.md)
|
||||
**Status:** CT 102 am 14.10.2026 nach reproduzierbarer Provisionierung,
|
||||
finalem PostgreSQL-Restore, NPM-Umschaltung, funktionaler Benutzerabnahme und
|
||||
produktiver Neustartabnahme in Betrieb; Stabilisierungsphase läuft
|
||||
**Ziel:** Den auf CT 104 manuell erarbeiteten und praktisch geprüften
|
||||
Keycloak-/PostgreSQL-Sollzustand als idempotente Ansible-Automatisierung
|
||||
abbilden, einen neuen CT 102 damit aufbauen und anschließend kontrolliert von
|
||||
CT 104 auf den automatisierten CT 102 wechseln.
|
||||
|
||||
## 1. Leitentscheidung und Grenzen
|
||||
|
||||
CT 104 ist zunächst der produktive Blue/Green-Zielserver und zugleich die
|
||||
beobachtete Referenz für die Automatisierung. Er bleibt bis zur vollständigen
|
||||
Abnahme des neuen CT 102 unverändert als Rückfallinstanz erhalten.
|
||||
|
||||
Der Betreiber hat die in GNAI-015 ausgeführten technischen Prüfungen sowie den
|
||||
realen Bitwarden-OIDC-Login einschließlich OTP als ausreichende
|
||||
Cutover-Validierung bestätigt und die direkte Wiederverwendung der CT-ID 102
|
||||
freigegeben. Der ausgeschaltete Altcontainer darf daher nach einer letzten
|
||||
Identitäts-, Backup- und Schutzprüfung entfernt werden. CT-ID 102 wird mit der
|
||||
ursprünglichen Dienst-IP `10.0.0.4` als unprivilegierter Debian-13-LXC über
|
||||
das Proxmox VE Helper Script für Podman neu erstellt. Die Konfiguration im
|
||||
Gast übernimmt anschließend Ansible.
|
||||
|
||||
Die am 12.10.2026 im laufenden CT 104 gemessene Referenz stellt 2 vCPUs,
|
||||
4096 MiB RAM, 1024 MiB Swap und 12 GiB Root-Speicher bereit. Bei rund 626 MiB
|
||||
aktuellem RAM- und 2,6 GiB Root-Speicherverbrauch übernimmt CT 102 CPU, RAM und
|
||||
Swap unverändert, erhält aber 16 GiB dünn bereitgestellten Root-Speicher für
|
||||
parallele Podman-Images, Updates und Ansible-Staging. Das endgültige
|
||||
Container-Storage wird vor der Erstellung anhand des PVE-Preflights bestätigt.
|
||||
|
||||
Das geschützte Stop-Backup des alten CT 102 bleibt bei der Löschung des
|
||||
Containers und während der gesamten zweiten Migration unverändert erhalten.
|
||||
Es wird nicht als Konfigurationsquelle für den Neubau verwendet und erst nach
|
||||
vollständiger Abnahme des automatisierten CT 102, abgeschlossener
|
||||
Stabilisierungsphase und ausdrücklicher Betreiberfreigabe entfernt. Bis dahin
|
||||
bilden CT 104 den operativen Rückfallweg und das Alt-Backup den historischen
|
||||
Katastrophen-Rückfallstand.
|
||||
|
||||
Nicht aus CT 104 kopiert werden:
|
||||
|
||||
- SSH-Hostschlüssel;
|
||||
- private Benutzer- oder Deployment-Schlüssel;
|
||||
- maschinenspezifische Laufzeit-IDs und Podman-Metadaten;
|
||||
- PostgreSQL-Dateien auf Dateisystemebene.
|
||||
|
||||
Anwendungsdaten werden über einen konsistenten PostgreSQL-Dump übertragen.
|
||||
Secrets bleiben außerhalb des Repositorys und werden bei der Ausführung über
|
||||
einen festgelegten Secret-Pfad oder Ansible Vault bereitgestellt.
|
||||
|
||||
## 2. Bewertung und Zielstruktur des Ansible-Control-Projekts
|
||||
|
||||
### 2.1 Bewertung des vorhandenen Projekts
|
||||
|
||||
Die vorhandene Struktur aus `playbooks/`, `roles/` und getrennten statischen
|
||||
Inventaren ist weiterhin eine von Ansible unterstützte und für dieses
|
||||
Repository angemessene Projektform. Sie ist nicht allein deshalb veraltet,
|
||||
weil Ansible zusätzlich Collections und Execution Environments anbietet.
|
||||
Collections sind primär eine Distributions- und Namensraumgrenze für zwischen
|
||||
Projekten geteilte Inhalte. Solange die Rollen ausschließlich in diesem
|
||||
Infrastruktur-Repository verwendet werden, würde eine sofortige Umstellung
|
||||
auf eine eigene Collection mehr Verpackung als praktischen Nutzen erzeugen.
|
||||
|
||||
Positiv am vorhandenen Stand sind:
|
||||
|
||||
- durchgängige Fully Qualified Collection Names wie `ansible.builtin.copy`;
|
||||
- benannte Tasks, explizite Zustände und überwiegend idempotente Module;
|
||||
- getrennte Bootstrap-, Konfigurations- und Verifikationsläufe;
|
||||
- Syntax-, Check-Mode- und `changed=0`-Abnahme;
|
||||
- keine privaten Schlüssel im Inventar oder Repository;
|
||||
- ein eng gepinntes `ansible-core` für den Control Node.
|
||||
|
||||
Vor der Keycloak-Implementierung werden folgende Punkte modernisiert:
|
||||
|
||||
- Das in `ansible.cfg` eingetragene Standardinventar
|
||||
`inventories/testing/hosts.yml` ist für ein Projekt mit mehreren produktiven
|
||||
Zielsystemen zu mehrdeutig. Produktive Befehle müssen ein Inventar explizit
|
||||
angeben; ein versehentlicher Lauf gegen den AI-Server darf nicht möglich
|
||||
sein.
|
||||
- Host-, Umgebungs- und Dienstwerte wandern aus Playbook-`vars` und
|
||||
zielsystemspezifischen Rollen-Defaults in klar benannte `group_vars` und
|
||||
`host_vars`. Rollen-Defaults bleiben überschreibbare, generische Vorgaben.
|
||||
- Neue wiederverwendbare Rollen erhalten `meta/argument_specs.yml`; manuelle
|
||||
Assertions bleiben für Live-Eigenschaften wie OS, LXC, IP, cgroup und freien
|
||||
Speicher bestehen.
|
||||
- `ansible-lint`, YAML-/Syntaxprüfung und Secret-Scans werden als lokale und
|
||||
CI-fähige Qualitätsgates ergänzt. Live-Integrationstests bleiben bewusst auf
|
||||
dem neu erzeugbaren LXC, weil generische Container-Molecule-Tests systemd,
|
||||
nftables, Quadlet und verschachteltes Podman nicht vollständig abbilden.
|
||||
- Python-, `ansible-core`-, Collection- und Lint-Abhängigkeiten werden
|
||||
vollständig gepinnt. Zusätzlich wird eine mit Podman ausführbare Ansible
|
||||
Execution Environment vorgesehen, damit lokale und spätere Runner-Läufe
|
||||
dieselbe Control-Umgebung verwenden können.
|
||||
- Die vorhandene `.venv` ist nach einer Verschiebung des Repositorys wegen
|
||||
absoluter Interpreterpfade nicht mehr ausführbar. Sie wird nicht repariert
|
||||
oder versioniert, sondern aus den gepinnten Abhängigkeiten neu erzeugt und
|
||||
anschließend durch die Execution Environment ergänzt beziehungsweise
|
||||
ersetzt.
|
||||
|
||||
### 2.2 Wiederverwendung vorhandener Inhalte
|
||||
|
||||
| Vorhandener Baustein | Entscheidung für Keycloak |
|
||||
| --- | --- |
|
||||
| `bootstrap-deploy-account.yml` | Sicherheitsmodell wiederverwenden, aber in eine generische Rolle mit Keycloak-eigenem Benutzer, Schlüssel und Inventar überführen |
|
||||
| `verify-deploy-access.yml` | auf eine generische Zielgruppe umstellen und unverändert das unprivilegierte Login plus nicht-interaktives sudo prüfen |
|
||||
| `podman_runtime` | Paket-, cgroup-v2-, Quadlet- und API/Auto-Update-Prüfungen wiederverwenden; Rootless-only-Annahmen vermeiden, da Keycloak rootful läuft |
|
||||
| `host_baseline` | nicht direkt anwenden, da Hostname, IP, Speichergrenze und `ghostnet-ai`-Pfade physisch-hostspezifisch sind; Muster für Assertions übernehmen |
|
||||
| `workload_accounts` | nicht anwenden; die Rolle verwaltet rootless AI-Servicekonten und Sub-ID-Bereiche statt interaktiver Administrationskonten |
|
||||
| `amd_gpu_access` und `rocm_container_probe` | vollständig außerhalb des Keycloak-Scopes |
|
||||
|
||||
### 2.3 Zielstruktur
|
||||
|
||||
Die während des Neuaufbaus gemeinsam unter `ansible/` entwickelte Automation
|
||||
wurde am 14.10.2026 nach Zielhost getrennt. CT-102-Inventare, Playbooks,
|
||||
Public Keys und Rollen liegen nun im CT-Root; nur die auch vom physischen
|
||||
KI-Server verwendete generische Podman-Rolle und der Control-Node-Pin bleiben
|
||||
geteilt:
|
||||
|
||||
```text
|
||||
proxmox/lxc/ct-102-keycloak/
|
||||
├── ansible/
|
||||
│ ├── ansible.cfg
|
||||
│ ├── inventories/{bootstrap,production,reference}/
|
||||
│ ├── playbooks/
|
||||
│ ├── roles/keycloak_*/
|
||||
│ └── files/ssh/
|
||||
└── scripts/
|
||||
|
||||
shared/ansible/
|
||||
├── requirements-control.txt
|
||||
└── roles/podman_runtime/
|
||||
|
||||
llm-server/ansible/ # physischer KI-Server, getrennt von CT 102
|
||||
```
|
||||
|
||||
Eine eigene Ansible Collection wird erst eingeführt, wenn dieselben Rollen in
|
||||
mehreren Repositories oder über AWX/Galaxy verteilt werden sollen oder eigene
|
||||
Plugins/Module einen stabilen Namensraum benötigen.
|
||||
|
||||
### 2.4 Zielrollen
|
||||
|
||||
Die Umsetzung übernimmt die Sicherheits- und Abnahmemuster des
|
||||
LLM-Server-Projekts: Identitäts-Preflight, getrenntes
|
||||
Bootstrap-Inventar, kleine Rollen, Syntaxprüfung, Check-Mode, zweiter Lauf mit
|
||||
`changed=0` und eigenständige Verifikations-Playbooks.
|
||||
|
||||
Vorgesehene Bausteine:
|
||||
|
||||
| Baustein | Verantwortung |
|
||||
| --- | --- |
|
||||
| Keycloak-Inventar | CT-ID, Hostname, `10.0.0.4`, OS-Version, erwarteter SSH-Hostschlüssel und nicht geheime Variablen |
|
||||
| Post-Install-Hook | auf dem PVE-Host nach dem Podman-Helper genau CT 102 prüfen und die drei initialen SSH-Konten einschließlich `ansible-deploy` einrichten |
|
||||
| Bootstrap-Playbook | den vom Hook geschaffenen Transport- und sudo-Zustand unabhängig prüfen und anschließend deklarativ übernehmen |
|
||||
| `keycloak_lxc_baseline` | Zielidentität, Pakete, Zeitzone, Verzeichnisse, sysctl und Grundzustand prüfen und verwalten |
|
||||
| `keycloak_access` | `ghost-agent` UID/GID 1000, `ghost` UID/GID 1001, Schlüssel, Passwort-/sudo-Zustand und SSH-Härtung deklarieren |
|
||||
| `keycloak_firewall` | nftables-Regeln, Netze, NPM-Quelle, SSH-Managementpfad und persistenten Dienst verwalten |
|
||||
| `keycloak_podman_runtime` | Podman/Quadlet, Generator, Rootful-Store und deaktivierte ungewollte API-/Auto-Update-Units prüfen |
|
||||
| `keycloak_postgres` | Netz, Volume, Init-Skript, eingeschränkte DB-Rolle, Secret-Referenzen, Quadlet und Healthcheck verwalten |
|
||||
| `keycloak_service` | gepinntes Build-Image, Containerfile, Keycloak-Quadlet, Ressourcenlimits, Hostnamen und Start-Gates verwalten |
|
||||
| Backup/Restore-Playbook | `pg_dump`, Prüfsummen, verschlüsselten Transport und Wiederherstellung in eine leere Datenbank ausführen und prüfen |
|
||||
| Verify-Playbook | SSH, sudo, nftables, Podman, PostgreSQL, Keycloak-Health, Ports, Reboot und Idempotenz ohne Secret-Ausgabe abnehmen |
|
||||
|
||||
Gemeinsame Rollen aus dem AI-Server-Projekt werden nur wiederverwendet, wenn
|
||||
ihre Annahmen zum physischen Host und zu rootless Workloads passen. Andernfalls
|
||||
werden kleine LXC-spezifische Rollen erstellt; die Keycloak-Automatisierung
|
||||
darf keine GPU-, AI- oder Hostkapazitätsannahmen erben.
|
||||
|
||||
## 3. Benutzer- und Zugriffsmodell
|
||||
|
||||
| Konto | Vorgesehene UID/GID | Zweck | Privilegien |
|
||||
| --- | ---: | --- | --- |
|
||||
| `ghost-agent` | 1000 | Read-only-Inventar, Health- und Sicherheitsprüfungen durch Agenten | kein sudo, keine Podman-Gruppe, keine Secret-Leserechte |
|
||||
| `ghost` | 1001 | persönlicher Betreiberzugang | passwortgeschütztes sudo; SSH ausschließlich per persönlichem Schlüssel |
|
||||
| `ansible-deploy` | 1002, sofern Preflight die ID als frei bestätigt | nicht-interaktive Konfiguration durch Ansible und ausdrücklich autorisierte Agenten | gesperrtes Passwort, eigener SSH-Schlüssel, validiertes `NOPASSWD: ALL` |
|
||||
|
||||
`ansible-deploy` ist technisch rootäquivalent. Es verwendet deshalb einen
|
||||
eigenen, nur für CT 102 bestimmten Ed25519-Schlüssel; der private Schlüssel
|
||||
bleibt außerhalb des Repositorys mit Modus `0600`. Der öffentliche Schlüssel
|
||||
wird mit `restrict` und `from=` auf die dokumentierten LAN- und
|
||||
Server-Admin-VPN-Quellnetze begrenzt. Ein lokales SSH-Profil verwendet
|
||||
`IdentitiesOnly yes`, eine separate Known-Hosts-Datei und den vorab über die
|
||||
PVE-Konsole bestätigten Hostschlüssel. So kann ein Agent nach ausdrücklicher
|
||||
Autorisierung durch den Betreiber idempotente Ansible-Läufe und gezielte
|
||||
administrative Prüfungen ohne Passwortdialog ausführen.
|
||||
|
||||
Das Konto `ghost-agent` bleibt davon strikt getrennt. Es erhält weder den
|
||||
Deployment-Schlüssel noch sudo- oder Schreibzugriff auf Podman, Quadlets,
|
||||
Secrets, PostgreSQL-Daten oder Backups.
|
||||
|
||||
Für den allerersten Bootstrap wird vorerst nicht der Ansible-Connection-Plugin
|
||||
`community.proxmox.proxmox_pct_remote` eingesetzt. Dieser könnte Tasks über
|
||||
SSH zum PVE-Host und dessen `pct` direkt im LXC ausführen, würde dafür aber ein
|
||||
zusätzliches, nicht-interaktives und privilegiertes Automationskonto auf dem
|
||||
gesamten Proxmox-Host erfordern. Das überschreitet die gewünschte
|
||||
CT-102-Sicherheitsgrenze. Stattdessen werden die drei Konten, ihre öffentlichen
|
||||
Schlüssel und der initiale sudoers-Eintrag einmalig durch den versionierten
|
||||
Community-Scripts-Post-Install-Hook
|
||||
`proxmox/lxc/ct-102-keycloak/scripts/ghostnet-keycloak-ansible-bootstrap-hook.sh`
|
||||
installiert. Der
|
||||
Helper führt ihn als root auf dem PVE-Host aus; der Hook verweigert andere
|
||||
CT-IDs, Hostnamen, IP-Adressen und Helper-Anwendungen und verändert den Gast
|
||||
ausschließlich über `pct exec`. Ein Hook-Fehler entfernt den bereits erzeugten
|
||||
LXC nicht automatisch. Deshalb bleiben Logprüfung, frische
|
||||
SSH-Hostschlüsselprüfung und positive wie negative Zugangstests verpflichtend.
|
||||
Ab dem separat verifizierten `ansible-deploy`-Login erfolgen alle weiteren
|
||||
Gaständerungen über Ansible. Ein späteres PVE-weites Automationskonto bleibt
|
||||
eine eigenständige Architekturentscheidung.
|
||||
|
||||
## 4. Phasen und Checkliste
|
||||
|
||||
### Phase 0 – Ansible-Projekt modernisieren
|
||||
|
||||
- [x] Vorhandene Playbooks, Inventare und Rollen auf Wiederverwendbarkeit und
|
||||
aktuelle Strukturprinzipien prüfen.
|
||||
- [x] Entscheidung dokumentieren: eigenständige Rollen bleiben für dieses
|
||||
interne Repository angemessen; noch keine eigene Collection.
|
||||
- [x] Unsicheres Standardinventar entfernen und alle Bedienbefehle auf
|
||||
explizite Inventare oder eindeutig benannte Wrapper umstellen.
|
||||
- [x] Keycloak-Bootstrap- und Produktionsinventar mit `group_vars` und
|
||||
`host_vars` anlegen.
|
||||
- [x] Rollen-Argument-Spezifikationen sowie YAML- und Syntaxprüfungen
|
||||
ergänzen.
|
||||
- [ ] `ansible-lint`, Secret-Scan und CI-Workflow ergänzen.
|
||||
- [x] Defekte lokale `.venv` aus gepinnten Abhängigkeiten neu erzeugen.
|
||||
- [ ] Eine mit Podman ausführbare, versionierte Execution Environment
|
||||
aufbauen.
|
||||
|
||||
### Phase A – CT 104 als Referenz erfassen
|
||||
|
||||
- [ ] Alle von GNAI-015 erzeugten Dateien, Units, Drop-ins, Quadlets,
|
||||
nftables-/sysctl-Regeln, Verzeichnisrechte, Benutzer-IDs, Images und
|
||||
Prüfsummen in eine Sollzustandsmatrix überführen. Der read-only Live-Abgleich
|
||||
vom 12.10.2026 bestätigte Debian 13.7, `ghost-agent` 1000:1000 ohne
|
||||
Zusatzgruppen, `ghost` 1001:1001 mit sudo, alle fünf Stack-Units aktiv und
|
||||
ohne fehlgeschlagene Unit. Netzwerk-, Volume-, PostgreSQL- und
|
||||
Keycloak-Quadlet sowie der Wait-Helper stimmen bytegenau mit dem Repository
|
||||
überein. Rootgeschützte Firewall-, sysctl-, Healthcheck- und Init-Dateien
|
||||
bleiben noch privilegiert abzugleichen.
|
||||
- [ ] Manuelle Zwischenstände, Testcontainer und nicht mehr wirksame Dateien
|
||||
von der produktiven Referenz unterscheiden.
|
||||
- [x] Geheime, hostgebundene und deklarativ verwaltbare Werte klassifizieren.
|
||||
- [x] Read-only-Audit gegen CT 104 schreiben, das keine Konfiguration ändert.
|
||||
|
||||
### Phase B – Sicheren Ansible-Zugang entwerfen
|
||||
|
||||
- [x] Eindeutigen SSH-Hostschlüssel des neuen CT 102 über die PVE-Konsole
|
||||
prüfen und im lokalen Trust Store aufnehmen.
|
||||
- [x] Dedizierten CT-102-Schlüssel für `ansible-deploy` erzeugen. Nur der
|
||||
öffentliche Schlüssel liegt als
|
||||
`proxmox/lxc/ct-102-keycloak/ansible/files/ssh/keycloak-ansible-deploy.pub`
|
||||
im Repository; der private
|
||||
Schlüssel liegt mit Modus `0600` außerhalb des Repositorys unter
|
||||
`~/.ssh/id_ed25519_ghostnet_ct102_ansible_deploy`. Bestätigter Ed25519-
|
||||
Fingerprint:
|
||||
`SHA256:Dic8CzoK5UjEzyufszNmMSbyLOCip0bfBp5uEhYvWFg`.
|
||||
- [x] Bootstrap-Verfahren für `ansible-deploy` als CT-102-spezifischen
|
||||
Community-Scripts-Post-Install-Hook implementieren; der Schlüssel
|
||||
muss für ausdrücklich autorisierte Agenten non-interaktiv nutzbar bleiben.
|
||||
- [x] PVE-weiten `proxmox_pct_remote`-Transport für diesen Scope bewusst
|
||||
verworfen; der Einmal-Bootstrap bleibt konsolengebunden und auf CT 102
|
||||
begrenzt.
|
||||
- [x] `ansible-deploy` mit gesperrtem Passwort, Quellenbeschränkung und bewusst
|
||||
dokumentiertem `NOPASSWD`-sudo einrichten und separat verifizieren.
|
||||
- [x] `ghost`, `ghost-agent` und `ansible-deploy` mit festen UID/GID, getrennten
|
||||
Schlüsseln, korrekten sudo-Rechten und gehärtetem SSH deklarieren.
|
||||
- [x] Negativtests belegen, dass `ghost-agent` weder sudo noch Secret-,
|
||||
Podman-, Quadlet-, Datenbank- oder Backup-Schreibzugriff besitzt.
|
||||
|
||||
### Phase C – Rollen und Playbooks implementieren
|
||||
|
||||
- [x] Read-only-Preflight für CT-ID-unabhängige Gastidentität, Debian 13,
|
||||
`10.0.0.4`, LXC, cgroup v2 und freien Speicher erstellen.
|
||||
- [x] Baseline-, Zugriffs-, nftables-, Podman-, PostgreSQL- und
|
||||
Keycloak-Rollen mit Handlern und Assertions implementieren.
|
||||
- [x] Bestehende geprüfte Artefakte aus
|
||||
`proxmox/lxc/ct-104-keycloak/` als Templates beziehungsweise Dateien
|
||||
wiederverwenden und deren Digests weiterhin pinnen.
|
||||
- [x] Secret-Injektion ohne Klartext in Git, Logs, Diff-Ausgaben oder
|
||||
Fact-Cache implementieren; geheime Tasks verwenden `no_log`.
|
||||
- [x] Commissioning-Gates so modellieren, dass weder Keycloak noch ein leerer
|
||||
konkurrierender Datenbestand vor dem Restore produktiv startet.
|
||||
- [x] Rollen-READMEs und Bedienbefehle parallel zur Implementierung pflegen.
|
||||
|
||||
### Phase D – Neuen CT 102 bereitstellen
|
||||
|
||||
- [x] Betreiberfreigabe zur direkten Wiederverwendung von CT-ID 102 nach dem
|
||||
erfolgreichen CT-104-Cutover dokumentieren.
|
||||
- [x] Vor der Löschung `status: stopped`, `onboot: 0`, `protection: 1`,
|
||||
Identität des Altcontainers sowie Pfad, Schutzstatus, Prüfsumme und
|
||||
Lesbarkeit seines Schlussarchivs erneut prüfen.
|
||||
- [x] Schutz des Altcontainers erst unmittelbar für dessen gezielte Löschung
|
||||
aufheben; das geschützte Backup dabei weder löschen noch verändern.
|
||||
- [x] Beide geprüften Hook-Skripte mit dokumentierter Prüfsumme rootgeschützt
|
||||
auf dem PVE-Host installieren und den CT-102-Hook ausschließlich in den
|
||||
erweiterten Einstellungen dieses Podman-Helper-Laufs auswählen.
|
||||
- [x] CT 102 über das Proxmox VE Helper Script als unprivilegierten
|
||||
Debian-13-Podman-LXC mit der geprüften CT-104-Ressourcenkonfiguration und
|
||||
`10.0.0.4` neu erstellen; den Post-Install-Log vollständig prüfen.
|
||||
- [x] Frische SSH-Hostschlüssel über die PVE-Konsole erfassen; alte
|
||||
CT-102-Hostschlüssel niemals übernehmen und veraltete lokale
|
||||
Known-Hosts-Einträge kontrolliert ersetzen.
|
||||
- [x] Die durch den Hook erzeugten Konten `ghost`, `ghost-agent` und
|
||||
`ansible-deploy` getrennt verifizieren, das persönliche `ghost`-Passwort
|
||||
ausschließlich interaktiv setzen und alle Negativtests ausführen.
|
||||
- [x] Unveränderten Ansible-Preflight ausführen, anschließend Bootstrap und
|
||||
Konfigurationsplaybook anwenden.
|
||||
- [x] Zweiten vollständigen Lauf mit `changed=0`, `failed=0` und
|
||||
`unreachable=0` nachweisen.
|
||||
- [x] Neustart des noch datenfreien LXC und erneuten Konvergenzlauf abnehmen.
|
||||
|
||||
### Phase E – Datenübernahme und zweiter Blue/Green-Cutover
|
||||
|
||||
- [x] CT 104 in ein administratives Änderungsfreeze setzen und einen
|
||||
konsistenten PostgreSQL-Dump einschließlich Rollen-/Eigentümerstrategie
|
||||
erzeugen.
|
||||
- [x] Dump verschlüsselt oder ausschließlich rootlesbar übertragen,
|
||||
Prüfsummen vergleichen und in die leere PostgreSQL-Instanz auf CT 102
|
||||
wiederherstellen.
|
||||
- [x] Keycloak auf CT 102 zunächst nur über einen internen Testhost starten;
|
||||
Realms, Benutzer, Clients, Rollen, Flows und Schlüssel vergleichen.
|
||||
- [x] Adminlogin sowie echten Vaultwarden-OIDC-Login testen.
|
||||
- [x] Sicherstellen, dass CT 104 und CT 102 niemals gleichzeitig als
|
||||
beschreibbare produktive Identitätsquelle dienen.
|
||||
- [x] NPM kontrolliert von `10.0.0.16:8080` auf `10.0.0.4:8080` umstellen und
|
||||
öffentliche sowie interne Positiv-/Negativtests wiederholen.
|
||||
- [x] Vollständigen produktiven LXC-Neustart, Keycloak-/PostgreSQL-Wiederanlauf,
|
||||
Persistenz des Restores und anschließende Ansible-Konvergenz praktisch
|
||||
nachweisen.
|
||||
- [ ] NPM-Rückfall auf CT 104 in einem kontrollierten Rückfalltest praktisch
|
||||
nachweisen; CT 104 bis dahin ausgeschaltet und unverändert lassen.
|
||||
|
||||
### Phase F – Automatisierung zur Quelle der Wahrheit machen
|
||||
|
||||
- [ ] CT 102 mindestens 14 Tage beobachten; CT 104 ausgeschaltet und
|
||||
unverändert als Rückfall behalten.
|
||||
- [ ] Nicht deklarierte Drift zwischen Ansible und CT 102 erfassen und
|
||||
beseitigen.
|
||||
- [ ] Betriebs-, Update-, Secret-Rotations-, Backup- und Restore-SOP anhand
|
||||
der Playbooks fertigstellen.
|
||||
- [ ] Nach Betreiberfreigabe CT 104 archivieren oder entfernen und Inventar,
|
||||
DNS/NPM-Dokumentation sowie Projektindex auf CT 102 aktualisieren.
|
||||
- [ ] Erst nach vollständiger Abnahme, Stabilisierungsphase und ausdrücklicher
|
||||
Betreiberfreigabe das historische Backup des ursprünglichen CT 102 gezielt
|
||||
entfernen und die Löschung einschließlich betroffener Volumes,
|
||||
Prüfsummenbelege und verbleibender Rückfallstände dokumentieren.
|
||||
|
||||
## 5. Abnahmekriterien
|
||||
|
||||
- Ein leerer, mit dem Helper Script erzeugter LXC erreicht ausschließlich
|
||||
durch dokumentierten Bootstrap plus Ansible denselben wirksamen
|
||||
Sicherheits- und Dienstzustand wie CT 104.
|
||||
- Ein unmittelbarer zweiter Lauf meldet keine Änderung.
|
||||
- Keine private Zugangsinformation und kein Klartext-Secret liegt im Git-
|
||||
Repository oder erscheint in Ansible-Ausgaben.
|
||||
- PostgreSQL ist nicht am Host veröffentlicht; Keycloak-Port 8080 akzeptiert
|
||||
nur NPM, SSH nur die Managementpfade.
|
||||
- Reboot, Keycloak-/PostgreSQL-Wiederanlauf, echter OIDC-Login, Backup und
|
||||
Restore sind praktisch belegt.
|
||||
- CT 104 bleibt bis zum Ende der zweiten Stabilisierungsphase ein getesteter
|
||||
Rückfallweg.
|
||||
- Das Alt-Backup des ursprünglichen CT 102 bleibt bis zur vollständigen
|
||||
Abnahme erhalten und wird nicht implizit durch Provisioning-, Retention- oder
|
||||
Cleanup-Tasks gelöscht.
|
||||
- `ghost-agent` bleibt nachweislich read-only; `ansible-deploy` ist als
|
||||
rootäquivalentes Automationskonto getrennt, quellenbeschränkt und
|
||||
revisionsfähig nutzbar.
|
||||
|
||||
## 6. Strukturentscheidung in Kurzform
|
||||
|
||||
- **Beibehalten:** YAML-Inventare, eigenständige Rollen, FQCNs,
|
||||
Bootstrap/Deploy/Verify-Trennung und explizite Idempotenzabnahme.
|
||||
- **Jetzt modernisieren:** sichere Inventarwahl, `group_vars`/`host_vars`,
|
||||
Rollen-Argument-Spezifikationen, Lint/CI, gepinnte Collections und eine
|
||||
Podman-basierte Execution Environment.
|
||||
- **Noch nicht einführen:** eigene Collection, AWX oder dynamisches Inventar;
|
||||
dafür besteht bei zwei statischen Keycloak-Hosts noch kein belastbarer
|
||||
Mehrwert.
|
||||
|
||||
## 7. Implementierungsstand vom 13.10.2026
|
||||
|
||||
CT 102 wurde als unprivilegierter Debian-13-LXC mit 2 vCPUs, 4096 MiB RAM,
|
||||
1024 MiB Swap und 16 GiB Root-Speicher neu erstellt. Der Post-Install-Hook
|
||||
richtete die drei getrennten Konten ein; Hostschlüssel, persönliche Anmeldung,
|
||||
Deployment-sudo sowie positive und negative Zugangstests wurden unabhängig
|
||||
bestätigt.
|
||||
|
||||
Das vollständige `playbooks/keycloak/site.yml` provisionierte anschließend
|
||||
Baseline, SSH-Härtung, nftables, Podman, das digest-gepinnte PostgreSQL und das
|
||||
lokal optimierte Keycloak-Image. Der unmittelbare zweite Lauf sowie der Lauf
|
||||
nach einem vollständigen LXC-Neustart meldeten jeweils:
|
||||
|
||||
```text
|
||||
ok=95 changed=0 unreachable=0 failed=0 skipped=11
|
||||
```
|
||||
|
||||
`verify-commissioning.yml` bestätigte nach dem Neustart mit
|
||||
`ok=22 changed=0 unreachable=0 failed=0`: PostgreSQL läuft als UID/GID
|
||||
999:999 gesund und bootpersistent, die Anwendungsdatenbank enthält noch keine
|
||||
Objekte, beide Secret-Quelldateien erfüllen die exakte 64-Byte-/0600-/root-
|
||||
Invariante, `ghost-agent` überschreitet keine privilegierte Grenze, die
|
||||
Hostports 5432/8080/9000 sind unbelegt, das Keycloak-Quadlet pinnt die
|
||||
optimierte Image-ID, nftables ist deny-by-default und es bestehen keine
|
||||
fehlgeschlagenen Units. Keycloak selbst ist gestoppt; Startmarker und Boot-Link
|
||||
fehlen absichtlich.
|
||||
|
||||
Beim ersten PostgreSQL-Commissioning enthielten die neu erzeugten Passwort-
|
||||
Quelldateien durch eine Template-Neuzeile 65 statt 64 Bytes. Der Fehler wurde
|
||||
vor jeglichem Restore erkannt. Ausschließlich das eindeutig frische und leere
|
||||
CT-102-Volume sowie die beiden zugehörigen CT-102-Podman-Secrets wurden
|
||||
entfernt und neu erzeugt; CT 104 blieb unberührt. Die Rolle persistiert
|
||||
Passwörter seither ohne Zeilenende und verweigert abweichende Größe, Format,
|
||||
Eigentümer oder Modus.
|
||||
|
||||
Beim automatisierten Neustart ist zu beachten, dass Ansible standardmäßig
|
||||
`/proc/sys/kernel/random/boot_id` vergleicht. Diese Kernel-Boot-ID gehört im
|
||||
LXC zum PVE-Host und ändert sich beim reinen Gast-Neustart nicht. Der Reboot
|
||||
wurde daher zwar ausgeführt, der generische Waiter lief jedoch in seinen
|
||||
Timeout. Die anschließende unabhängige SSH-, Dienst-, Commissioning- und
|
||||
Konvergenzprüfung belegte den erfolgreichen Neustart. Zukünftige LXC-Reboot-
|
||||
Automatisierung muss einen gastbezogenen `boot_time_command` verwenden oder
|
||||
über PVE orchestriert werden.
|
||||
|
||||
Für Phase E wurden
|
||||
`proxmox/lxc/ct-102-keycloak/scripts/prepare-keycloak-cutover-dump.sh` und
|
||||
`proxmox/lxc/ct-102-keycloak/ansible/playbooks/restore.yml` verwendet. Das
|
||||
PVE-Skript hat
|
||||
SHA-256
|
||||
`7bc07d03230e208d28465e07bee16370d80293541eeea97f4f28e5d1b946a291`.
|
||||
Es prüft Quelle, leeres und gegatetes Ziel sowie beide NPM-Upstreams, stoppt
|
||||
den Keycloak-Anwendungsdienst auf CT 104, erzeugt und katalogisiert einen
|
||||
Custom-Dump und überträgt ihn mit dreifacher Prüfsummenkontrolle nach CT 102.
|
||||
Der LXC und PostgreSQL dürfen dabei für den Rückfall weiterlaufen; entscheidend
|
||||
ist der gestoppte Keycloak-Dienst ohne Listener auf Port 8080. Vor vollständig
|
||||
verifiziertem Transfer startet der Fehler-Trap den CT-104-Keycloak-Dienst
|
||||
wieder; nach Erfolg bleibt die Quelle auf Anwendungsebene eingefroren. Das
|
||||
Restore-Playbook wurde vorab fail-closed geprüft und anschließend genau einmal
|
||||
gegen die leere Datenbank ausgeführt.
|
||||
|
||||
## 8. Cutover-Stand vom 14.10.2026
|
||||
|
||||
Der finale CT-104-Dump
|
||||
`keycloak-final-ct104-20261013T170621.dump` wurde nach dem Stoppen der alten
|
||||
Keycloak-Instanz mit SHA-256
|
||||
`31b2337756b0ca6e2f3fc00a6b2deff6ba735f750363608059c4e6e6b0e51ed7`
|
||||
dreifach verifiziert und rootlesbar nach CT 102 übertragen. Der transaktionale
|
||||
Restore bestätigte 88 öffentliche Datenbankobjekte, 152 Liquibase-Einträge und
|
||||
die erwarteten drei Realm-Zusammenfassungen. Der Keycloak-Dienst auf CT 104
|
||||
blieb danach gestoppt, während LXC und PostgreSQL zunächst als Rückfallbasis
|
||||
weiterliefen. CT 102 wurde erst nach abgeschlossenem Restore beschreibbar
|
||||
gestartet. Nach dem erfolgreichen Proxy-Cutover fuhr der Betreiber CT 104
|
||||
vollständig herunter.
|
||||
|
||||
Vor der Proxy-Umschaltung bestanden direkte Backendtests für `master`,
|
||||
`outline` und `Ghost.net`, den kanonischen Issuer, die interne Admin-Konsole
|
||||
und den Cross-Origin-Login-Status. NPM-Proxy-Hosts 7 und 13 wurden anschließend
|
||||
von `10.0.0.16:8080` auf `10.0.0.4:8080` umgestellt. Danach ergaben die
|
||||
öffentliche Discovery HTTP 200, die öffentliche Admin-Sperre HTTP 403 und die
|
||||
interne Admin-Konsole HTTP 200.
|
||||
|
||||
Das neue rein lesende `verify-production.yml` bestätigte mit
|
||||
`ok=28 changed=0 unreachable=0 failed=0` zusätzlich Restore-Herkunft,
|
||||
Boot- und Restart-Policy, Container-Härtung, Datenbankmodell, Listener,
|
||||
nftables und alle Proxy-Sicherheitsgrenzen. Der unmittelbar folgende
|
||||
vollständige Konvergenzlauf meldete:
|
||||
|
||||
```text
|
||||
ok=97 changed=0 unreachable=0 failed=0 skipped=9
|
||||
```
|
||||
|
||||
Nach dem vollständigen Herunterfahren von CT 104 wurde diese Abnahme
|
||||
wiederholt: CT 104 war weder per SSH noch von CT 103 auf Port 8080 erreichbar,
|
||||
CT 102 antwortete direkt mit HTTP 200, `verify-production.yml` erneut mit
|
||||
`ok=28 changed=0 failed=0` und `site.yml` erneut mit
|
||||
`ok=97 changed=0 failed=0`. Die externen Statuscodes blieben 200 für
|
||||
Discovery, 403 für die öffentliche Admin-Sperre, 200 für die interne
|
||||
Admin-Konsole und 204 für die Login-Status-Initialisierung.
|
||||
|
||||
Der Betreiber bestätigte anschließend die erfolgreiche interaktive Anmeldung
|
||||
an `auth-admin.ghostnet.selfhost.eu` sowie eine erfolgreiche reale
|
||||
Bitwarden-Anmeldung mit Keycloak-Authentifizierung. Damit sind nicht nur die
|
||||
HTTP- und Datenmodellprüfungen, sondern auch die beiden nutzungsrelevanten
|
||||
Authentifizierungspfade auf CT 102 praktisch belegt.
|
||||
|
||||
Der anschließend ausgeführte vollständige Neustart von CT 102 erzeugte beide
|
||||
Podman-Container neu. Die Keycloak-ID wechselte von
|
||||
`513e797d6f5cc8288662ac7e6f2106bace9fc2ab1dbe2e999b0fc1c00929c75a` auf
|
||||
`bebb727c8fa543606b126ba1fbc4fe1788ef96b0a72902acc2dd0808a6931443`,
|
||||
die PostgreSQL-ID von
|
||||
`3b9cc3a4132bc7139ee04c334d4855ccafa7fb94328664920bf06cc9c83d4a17` auf
|
||||
`fcd4f82b9c55f0b16f4341827a91ae44dcd922b20575a3d4149e9b5a87ca496d`.
|
||||
Beide Dienste und Healthchecks wurden wieder aktiv beziehungsweise gesund;
|
||||
im Gast blieben keine fehlgeschlagenen Units zurück. Die nachgelagerte
|
||||
Produktionsprüfung meldete erneut
|
||||
`ok=28 changed=0 unreachable=0 failed=0 skipped=0`, der vollständige
|
||||
Konvergenzlauf `ok=97 changed=0 unreachable=0 failed=0 skipped=9`. Damit sind
|
||||
auch Bootreihenfolge, Wiederanlauf und die Persistenz des wiederhergestellten
|
||||
Datenbestands nach einem realen LXC-Neustart belegt.
|
||||
|
||||
Offen bleibt nur der bewusst separat durchzuführende praktische NPM-Rückfall
|
||||
auf CT 104. CT 104 bleibt bis dahin ausgeschaltet und unverändert, das
|
||||
geschützte historische CT-102-Backup bleibt bis zum Ende der
|
||||
Stabilisierungsphase unangetastet.
|
||||
+27
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
**Dokumenten-ID:** GNAI-006-SEC
|
||||
**Titel:** Reverse Proxy und Fail2Ban – Konfiguration, Betrieb und Sicherheitsabnahme
|
||||
**Status:** NPM 2.15.1 und Fail2Ban aktiv; SSH-Rollout und lokale Dateirechte gehärtet; Keycloak-Admin-Exposure offen
|
||||
**Status:** NPM 2.15.1 und Fail2Ban aktiv; Keycloak-Public/Admin-Trennung seit 14.10.2026 abgenommen; Fail2Ban-Funktionsabnahme offen
|
||||
|
||||
**Vorgänger / Referenzen:** [Architektur](../../../llm-server/ai/GNAI-004-INFRA_Architecture.md), [LXC-SSH](../../GNAI-011-SEC_LXC-SSH-Architecture.md), [Zugriffskonzept](../../../GNAI-005-SEC_Access-Control.md), [WireGuard](../ct-105-wireguard/GNAI-007-SEC_VPN.md)
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
| 2026-09-24 | 2.4 | Codex | LAN- und WireGuard-Erreichbarkeit als bewusste einfache SSH-Netzbaseline bestätigt; engere Workstation-Allowlist verworfen. |
|
||||
| 2026-09-24 | 2.5 | Codex | Kontrollierten Neustart und Wiederanlauf von SSH, nftables, NPM, Fail2Ban und öffentlichen Proxy-Pfaden erfolgreich abgenommen. |
|
||||
| 2026-09-24 | 2.6 | Codex | Konsistentes Stop-Mode-vzdump erstellt, Archivintegrität und Prüfsumme geprüft sowie Dienstwiederanlauf regressionsgetestet. |
|
||||
| 2026-10-05 | 2.7 | Codex | Keycloak-Adminpfade, Split-DNS, internen TLS-vHost und wirksame öffentliche Hostname-/Issuer-Weiterleitungen erneut geprüft. |
|
||||
| 2026-10-14 | 2.8 | Codex | NPM auf den reproduzierten CT 102 umgeschaltet; öffentliche Admin-Sperre 403, interner Admin-vHost 200, Discovery/OIDC und realer Vaultwarden-Login abgenommen. |
|
||||
|
||||
---
|
||||
|
||||
@@ -433,6 +435,13 @@ ergänzen, ersetzt aber kein unabhängiges Backup.
|
||||
|
||||
## 11. Keycloak: bestätigte öffentliche Administration und Korrekturverfahren
|
||||
|
||||
> **Aktueller Stand 14.10.2026:** Die folgenden Abschnitte 11.1 bis 11.3
|
||||
> bewahren Befund und Korrekturverfahren. Die Korrektur ist inzwischen
|
||||
> umgesetzt: Proxy Hosts 7 und 13 zeigen auf CT 102 (`10.0.0.4:8080`), die
|
||||
> öffentliche Admin-Konsole liefert 403, der interne TLS-vHost 200, die
|
||||
> öffentliche Discovery den kanonischen Issuer und der reale
|
||||
> Vaultwarden-OIDC-Login wurde bestätigt.
|
||||
|
||||
### 11.1 Bestätigter Ist-Stand
|
||||
|
||||
Der Betreiber konnte **von extern** `https://auth.ghostnet.selfhost.eu/admin` öffnen und sich anmelden. Das widerspricht GNAI-005. Die Korrektur wurde bei dieser Dokumentationsrevision noch nicht auf dem System durchgeführt.
|
||||
@@ -466,6 +475,21 @@ Der erneute externe Abgleich am 22.09.2026 ergab:
|
||||
| Master-Realm | `/realms/master/account/` antwortete mit einem Redirect. Der Master-Realm ist über den öffentlichen Host erreichbar. |
|
||||
| Vaultwarden-Webkonfiguration | `/api/config` liefert `environment.sso` als leeren String. Das widerspricht der bisherigen Betreiberangabe eines produktiven OIDC-Logins, beweist allein aber nicht, dass serverseitig kein OIDC-Client existiert. |
|
||||
|
||||
Der erneute Abgleich am 05.10.2026 bestätigt den Korrekturbedarf:
|
||||
|
||||
| Prüfung | Beobachtung |
|
||||
| --- | --- |
|
||||
| Split-DNS | `auth-admin.ghostnet.selfhost.eu` löst ausschließlich zu NPM unter `10.0.0.10` auf. |
|
||||
| Interner TLS-vHost | Der TLS-Handshake für den Adminnamen endet mit `unrecognized name`; ein passender NPM-vHost mit Zertifikat ist noch nicht aktiv. |
|
||||
| Öffentlicher Adminpfad | `https://auth.ghostnet.selfhost.eu/admin` leitet weiterhin auf `/admin/master/console/` um; die Konsole antwortet dort mit HTTP 200. |
|
||||
| Backend-Redirect | `http://10.0.0.4:8080/admin` sowie direkte Tests mit öffentlichem und internem `Host`-/`X-Forwarded-Host`-Header leiten beide auf den öffentlichen Host `https://auth.ghostnet.selfhost.eu/admin/master/console/`. Ein separater wirksamer Adminhostname ist damit nicht vorhanden. |
|
||||
| Master-Realm | Die Discovery-Metadaten nennen `https://auth.ghostnet.selfhost.eu/realms/master` als Issuer; der Realm ist weiterhin öffentlich. |
|
||||
|
||||
Die genaue Keycloak-Version und die startwirksamen `KC_HOSTNAME*`- sowie
|
||||
Proxyparameter lassen sich aus diesen externen Antworten nicht zuverlässig
|
||||
bestimmen. Sie müssen vor einer Änderung direkt im Keycloak-LXC ohne Ausgabe
|
||||
von Datenbank- oder Bootstrap-Geheimnissen inventarisiert und gesichert werden.
|
||||
|
||||
Die Sperre von `/admin` kann unabhängig vom Anwendungsrealm erfolgen. Eine
|
||||
zusätzliche Sperre von `/realms/master/` bleibt bis zur Identifikation des
|
||||
tatsächlichen Vaultwarden-Issuers zurückgestellt. Der aktuelle lokale
|
||||
@@ -544,7 +568,7 @@ LLM Admins dürfen nur `.90` erreichen und haben keinen allgemeinen NPM-/LXC-Zug
|
||||
|
||||
| Befund | Abschlussnachweis |
|
||||
| --- | --- |
|
||||
| Keycloak Admin extern nutzbar | externe Sperre von GUI und API über tatsächliche HTTP/HTTPS-/IPv6-Pfade, interner Adminzugang und Vaultwarden-OIDC weiter funktionsfähig |
|
||||
| TLS-Zuordnung des auth-Hosts unklar | effektiver Listener, Zertifikat, Redirects und Backend-Pfad dokumentiert |
|
||||
| Keycloak Admin extern nutzbar | am 14.10.2026 geschlossen: öffentlich 403, intern 200, OIDC und realer Vaultwarden-Login bestanden |
|
||||
| TLS-Zuordnung des auth-Hosts unklar | am 14.10.2026 für öffentlichen und internen HTTPS-vHost funktional abgenommen |
|
||||
| Fail2Ban auf `1.1.0-r2-ls43` gepinnt und Container wieder laufend | effektives Image und Version dokumentiert; Jail-/Filter-/Ban-/Dienstneustart-/Reboottests bestanden |
|
||||
| NPM-Daten und Logs lokal weltlesbar beziehungsweise weltbeschreibbar | am 22.09.2026 korrigiert und positiv/negativ regressionsgeprüft; künftige Dateierzeugung und Logrotation weiter beobachten |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# CT 105 – WireGuard-Gateway
|
||||
|
||||
**Dokumenten-ID:** GNAI-007-SEC
|
||||
**Status:** Native nftables-Rollenpolicy aktiv; Legacy-Hooks entfernt; Fremdnetz- und Rollenabnahme offen
|
||||
**Status:** Native nftables-Rollenpolicy aktiv; Legacy-Hooks entfernt; Server-Admin-Rolle und Wiederanlauf abgenommen
|
||||
|
||||
**Referenzen:** [Zielarchitektur](../../../llm-server/ai/GNAI-004-INFRA_Architecture.md), [LXC-SSH](../../GNAI-011-SEC_LXC-SSH-Architecture.md), [Zugriffskonzept](../../../GNAI-005-SEC_Access-Control.md), [GUI-SOP](GNAI-007-SEC_VPN_Client-Management.md), [Implementation-Log](GNAI-007-SEC_VPN_Implementation-Log.md)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
| 2026-09-18 | 2.1 | spektr | Lesenden Gateway-Abgleich, iptables-legacy-Befund und DNS-Abgleich ergänzt; Betriebshinweise entsprechend korrigiert. |
|
||||
| 2026-09-22 | 2.2 | Codex | Gateway, Container, Regelwerke, Peers, Listener und SSH-Baseline erneut live geprüft; Schlüsseldateien auf Modus 0600 gehärtet. |
|
||||
| 2026-10-05 | 2.3 | Codex | Server-Admin-Rolle auf native nftables-Sets und natives NAT umgestellt; parallele Legacy-Hook-Bereinigung und externe Abnahme bleiben offen. |
|
||||
| 2026-10-06 | 2.4 | Codex | Abgeschlossene Fremdnetz-, Rollen- und Wiederanlauftests nachgeführt; CT 104 als neues inventarisiertes Server-Admin-Ziel ergänzt. |
|
||||
|
||||
## 1. Zweck und dokumentierter Stand
|
||||
|
||||
@@ -22,7 +23,16 @@ Das Gateway ermöglicht administrativen und familiären Fernzugriff auf das Heim
|
||||
|
||||
Das WireGuard-Gateway läuft bereits. Die nachfolgende Installation beschreibt seinen reproduzierbaren Aufbau; neue Sicherheitsmaßnahmen sind als **Soll** gekennzeichnet. Grundlage sind die Quellen, die Tests vom 12.09.2026, die vom Betreiber bereitgestellte Konfiguration und die lesenden Gateway-Abgleiche vom 18. und 22.09.2026. Am 22.09.2026 wurden ausschließlich die Dateirechte von `wg-easy.db` und dem historischen `wg0.json` auf `0600` reduziert; Client- und Rollentests wurden nicht durchgeführt.
|
||||
|
||||
**Wesentliche offene Umsetzung:** Die experimentelle wg-easy-Per-Client-Firewall bleibt deaktiviert. Seit 05.10.2026 begrenzen native nftables-Sets den Server-Admin-Laptop auf inventarisierte Zielhosts und erlauben Gateway-SSH/-GUI für seine VPN-IPs. Die alten wg-easy-`iptables-legacy`-Hooks und Laufzeitregeln sind entfernt; ein kontrollierter wg-easy-Neustart erzeugte sie nicht erneut. Positive und negative Rollen- sowie Fremdnetztests stehen noch aus. HTTPS für die wg-easy-GUI ist noch nicht eingerichtet. Der aktuelle Live-Abgleich steht im Implementation-Log.
|
||||
**Wesentliche offene Umsetzung:** Die experimentelle
|
||||
wg-easy-Per-Client-Firewall bleibt deaktiviert. Seit 05.10.2026 begrenzen
|
||||
native nftables-Sets den Server-Admin-Laptop auf inventarisierte Zielhosts und
|
||||
erlauben Gateway-SSH/-GUI für seine VPN-IP. Die alten
|
||||
wg-easy-`iptables-legacy`-Hooks und Laufzeitregeln sind entfernt; Fremdnetz-,
|
||||
Positiv-, Negativ-, Dienstneustart- und LXC-Wiederanlauftests sind bestanden.
|
||||
Am 06.10.2026 wurde CT 104 unter `10.0.0.16` serverseitig in die Zielmenge
|
||||
aufgenommen; die aktualisierte Clientroute und der Zugriffstest auf dieses neue
|
||||
Ziel stehen noch aus. HTTPS für die wg-easy-GUI ist noch nicht eingerichtet.
|
||||
Der aktuelle Live-Abgleich steht im Implementation-Log.
|
||||
|
||||
## 2. Architektur und Adressplan
|
||||
|
||||
|
||||
+13
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
**Dokumenten-ID:** GNAI-007-SEC-SOP-01
|
||||
**Titel:** Clients in der wg-easy-GUI einrichten und verwalten
|
||||
**Status:** Native nftables-Rollenpolicy teilweise umgesetzt; Legacy-Hook-Bereinigung und Abnahme offen
|
||||
**Status:** Native nftables-Rollenpolicy aktiv; Legacy-Hooks entfernt; Server-Admin-Rolle abgenommen
|
||||
**Referenzen:** [Gateway-Architektur](GNAI-007-SEC_VPN.md), [Rollenmodell](../../../GNAI-005-SEC_Access-Control.md), [Windows](GNAI-007-SEC_VPN_Clients_Windows11.md), [Linux](GNAI-007-SEC_VPN_Clients_Linux.md)
|
||||
|
||||
## Änderungsverlauf
|
||||
@@ -11,6 +11,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-09-15 | 1.0 | spektr | Eigenständige SOP für Rollenzuordnung, Profilausgabe, Änderung, Widerruf und Abnahme angelegt. |
|
||||
| 2026-09-18 | 1.1 | spektr | Offenen Umsetzungsstand der Rollen-Firewall, Gateway-INPUT-Regeln und negative Rollentests präzisiert. |
|
||||
| 2026-10-06 | 1.2 | Codex | Abgenommenen nativen Rollenpfad nachgeführt und CT 104 unter `10.0.0.16` in das Server-Admin-Inventar aufgenommen. |
|
||||
|
||||
## 1. Zweck und Voraussetzungen
|
||||
|
||||
@@ -18,7 +19,12 @@ Administratoren erstellen, ändern, verteilen und widerrufen WireGuard-Profile.
|
||||
|
||||
Benötigt werden ein Administratorlogin, Zugriff auf `http://10.0.0.15:51821` aus einem erlaubten Netz und ein aktuelles Serverinventar. Der dokumentierte Endpoint ist `vpn.ghostnet.selfhost.eu`; vor Profilausgabe seine DNS-Auflösung mit der aktuellen WAN-IP vergleichen. HTTPS ist geplant, noch nicht eingerichtet. Ein eigener Peer pro Gerät ermöglicht gezielten Widerruf.
|
||||
|
||||
Vor Rollenvergabe müssen native nftables-Zielsets und Gateway-INPUT-Regeln nach der Architektur eingerichtet und geprüft werden. Die Policy wurde am 05.10.2026 geladen, ist aber bis zur Entfernung paralleler Legacy-Hooks und zu den Rollen-Negativtests noch nicht abgenommen. Diese SOP erklärt bestehende Peers nicht rückwirkend für sicher.
|
||||
Vor Rollenvergabe müssen native nftables-Zielsets und Gateway-INPUT-Regeln nach
|
||||
der Architektur eingerichtet und geprüft werden. Die Server-Admin-Policy wurde
|
||||
am 05.10.2026 einschließlich Entfernung paralleler Legacy-Hooks,
|
||||
Fremdnetzprüfung, Negativtest und Wiederanlauf abgenommen. Neue Ziele gelten
|
||||
erst nach ihrer eigenen Profil-, Firewall- und Zugriffskontrolle als
|
||||
freigegeben.
|
||||
|
||||
## 2. Rollenwerte festlegen
|
||||
|
||||
@@ -28,10 +34,13 @@ Vor Rollenvergabe müssen native nftables-Zielsets und Gateway-INPUT-Regeln nach
|
||||
| Server Admin | freigegebene Server jeweils `/32` | dieselben Serveradressen ausdrücklich eintragen | `10.0.0.14` |
|
||||
| LLM Admin | `10.0.0.90/32` | `10.0.0.90/32` | leer / System-DNS |
|
||||
|
||||
Für Server Admins enthält das Quelleninventar derzeit Proxmox `.2` sowie `.4`, `.10`, `.11`, `.12`, Pi-hole `.14`, `.15`, `.17`, `.23`, `.47` und KI-Server `.90`. Vor Vergabe in Proxmox mit der tatsächlichen Belegung abgleichen. Beispiel nach bestätigtem Inventarabgleich:
|
||||
Für Server Admins enthält das Quelleninventar derzeit Proxmox `.2` sowie `.4`,
|
||||
`.10`, `.11`, `.12`, Pi-hole `.14`, `.15`, den neuen Keycloak-Ziel-LXC `.16`,
|
||||
`.17`, `.23`, `.47` und KI-Server `.90`. Vor Vergabe in Proxmox mit der
|
||||
tatsächlichen Belegung abgleichen. Beispiel nach bestätigtem Inventarabgleich:
|
||||
|
||||
```text
|
||||
10.0.0.2/32, 10.0.0.4/32, 10.0.0.10/32, 10.0.0.11/32, 10.0.0.12/32, 10.0.0.14/32, 10.0.0.15/32, 10.0.0.17/32, 10.0.0.23/32, 10.0.0.47/32, 10.0.0.90/32
|
||||
10.0.0.2/32, 10.0.0.4/32, 10.0.0.10/32, 10.0.0.11/32, 10.0.0.12/32, 10.0.0.14/32, 10.0.0.15/32, 10.0.0.16/32, 10.0.0.17/32, 10.0.0.23/32, 10.0.0.47/32, 10.0.0.90/32
|
||||
```
|
||||
|
||||
Das ist eine Inventarliste, keine behauptete vollständige LXC-Erfassung. Neue Server einzeln ergänzen. Der geplante statische Bereich bis `.99` wird nicht automatisch freigegeben. Die Fritzbox `.1` ist keine implizite Server-Admin-Freigabe. Pi-hole `.14` ist bewusst enthalten, damit interne Namen auch im fremden Netz aufgelöst werden.
|
||||
|
||||
+19
@@ -14,6 +14,7 @@
|
||||
| 2026-09-18 | 2.2 | spektr | Lesenden SSH-Abgleich von CT 105 und Grenzen des Nachweises dokumentiert. |
|
||||
| 2026-09-22 | 2.3 | Codex | aktuellen Gateway-Zustand live inventarisiert und Berechtigungen der Schlüsseldateien gehärtet |
|
||||
| 2026-10-05 | 2.4 | Codex | Peerrotation, native nftables-Rollenpolicy, Fremdnetztests sowie Stop-Backup und Wiederanlauf dokumentiert |
|
||||
| 2026-10-07 | 2.5 | Codex | CT 104 als zwölftes Server-Admin-Ziel aufgenommen und den Fremdnetzzugriff einschließlich Routing, NAT, Ziel-SSH und Backend-Sperre abgenommen |
|
||||
|
||||
## 1. Evidenz und zeitliche Einordnung
|
||||
|
||||
@@ -282,6 +283,23 @@ sämtliche Forwarding-Arten effektiv aus, Kerndienste und native Firewall aktiv,
|
||||
Legacy-Regeln leer, genau ein Peer vorhanden, SQLite intakt und die GUI
|
||||
erreichbar.
|
||||
|
||||
Am 06.10.2026 wurde `10.0.0.16` als zwölftes inventarisiertes Ziel in
|
||||
`server_admin_targets` aufgenommen. Die zuvor wirksame nftables-Datei liegt
|
||||
rootgeschützt unter
|
||||
`/var/backups/ghostnet/firewall/nftables.conf.pre-ct104-target-2026-10-06`;
|
||||
Kandidat und installierte Datei bestanden die Syntaxprüfung. Reload ließ
|
||||
nftables, wg-easy und Netavark aktiv. Das geschützte Profil sowie die
|
||||
NetworkManager-Verbindung `ghost-laptop` erhielten dieselbe zusätzliche
|
||||
`/32`-Route; die wg-easy-Einstellung wurde entsprechend nachgeführt.
|
||||
|
||||
Der Fremdnetztest am 07.10.2026 führte `10.0.0.16` über das Interface
|
||||
`ghost-laptop` mit Quelle `10.8.0.3`. Ping, Split-DNS und der gehärtete
|
||||
Agenten-SSH-Zugang zu CT 104 bestanden. CT 105 zählte 82 neue Verbindungen zur
|
||||
Serverzielmenge und dieselben 82 NAT-Bindungen ohne Drop; CT 104 beobachtete
|
||||
bei SSH die erwartete maskierte Quelle `10.0.0.15`. TCP 8080 lief für den
|
||||
VPN-Admin in einen Timeout und blieb damit weiterhin ausschließlich CT 103
|
||||
vorbehalten.
|
||||
|
||||
## 10. Offene Umsetzung und Abnahme
|
||||
|
||||
| ID | Arbeit | Status |
|
||||
@@ -296,5 +314,6 @@ erreichbar.
|
||||
| O8 | Reboot, Firewall-Reload und Restore | Stop-Backup mit vollständigem LXC-Wiederanlauf und persistenter Firewall bestanden; Restore noch offen |
|
||||
| O9 | wg-easy-GUI-HTTPS und lokales Gruppenmodell | geplant; Werte noch festzulegen |
|
||||
| O10 | SSH-Forwarding, X11 und automatisches Updateverfahren entscheiden | abgeschlossen: alle SSH-Forwarding-Arten aus; kontrolliertes manuelles Patchverfahren mit Simulation, Wartungsfenster und Health-Check festgelegt |
|
||||
| O11 | Neue Serverziele synchron in Gateway-Set und Clientprofil aufnehmen | Verfahren mit CT 104 / `10.0.0.16` am 07.10.2026 einschließlich Fremdnetz-, NAT-, SSH- und Backend-Negativtest bestanden |
|
||||
|
||||
Andere Sicherheitsbefunde werden im [Dokumentationsindex](../../../../GNAI-000-DOC_Index.md) geführt. Dieses Log hält nur WireGuard-Umsetzung und zugehörige Tests fest.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# LLM server `ghostnet-ki`
|
||||
|
||||
This host root contains executable infrastructure for the physical server at
|
||||
`10.0.0.90`. Host-specific Ansible inventories, playbooks and roles live under
|
||||
[`ansible/`](ansible/README.md). Reusable roles that are also consumed by
|
||||
other host projects live under [`../shared/ansible/`](../shared/ansible/).
|
||||
|
||||
The versioned architecture and operating documentation remains under
|
||||
[`../docs/platform/llm-server/`](../docs/platform/llm-server/README.md).
|
||||
@@ -1,4 +1,4 @@
|
||||
# Ansible control project
|
||||
# LLM server Ansible control project
|
||||
|
||||
This directory contains the reproducible control project for `ghostnet-ki`.
|
||||
The read-only `playbooks/preflight.yml` verifies the documented host identity
|
||||
@@ -14,7 +14,7 @@ root:
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -r ansible/requirements-control.txt
|
||||
.venv/bin/python -m pip install -r shared/ansible/requirements-control.txt
|
||||
```
|
||||
|
||||
Load the private key for the read-only `ghost-agent` account into an SSH agent.
|
||||
@@ -30,13 +30,20 @@ continuing.
|
||||
|
||||
## Read-only preflight
|
||||
|
||||
Run the commands from the `ansible/` directory:
|
||||
Run the commands from the host project directory:
|
||||
|
||||
```bash
|
||||
cd ansible
|
||||
../.venv/bin/ansible-inventory --graph
|
||||
../.venv/bin/ansible-playbook playbooks/preflight.yml --syntax-check
|
||||
../.venv/bin/ansible-playbook playbooks/preflight.yml
|
||||
cd llm-server/ansible
|
||||
../../.venv/bin/ansible-inventory \
|
||||
--inventory inventories/testing/hosts.yml \
|
||||
--graph
|
||||
../../.venv/bin/ansible-playbook \
|
||||
--inventory inventories/testing/hosts.yml \
|
||||
playbooks/preflight.yml \
|
||||
--syntax-check
|
||||
../../.venv/bin/ansible-playbook \
|
||||
--inventory inventories/testing/hosts.yml \
|
||||
playbooks/preflight.yml
|
||||
```
|
||||
|
||||
The final recap must report `changed=0` and `failed=0`. The playbook verifies:
|
||||
@@ -78,12 +85,12 @@ nevertheless grant root-equivalent deployment access; rotate it when moving the
|
||||
deployment identity to the runner or after any suspected disclosure.
|
||||
|
||||
Load the existing personal administrator key into an SSH agent, then run the
|
||||
bootstrap interactively from the `ansible/` directory. Ansible will ask for the
|
||||
bootstrap interactively from `llm-server/ansible/`. Ansible will ask for the
|
||||
personal administrator's existing sudo password:
|
||||
|
||||
```bash
|
||||
ssh-add /path/to/personal-ghost-private-key
|
||||
../.venv/bin/ansible-playbook \
|
||||
../../.venv/bin/ansible-playbook \
|
||||
--inventory inventories/bootstrap/hosts.yml \
|
||||
playbooks/bootstrap-deploy-account.yml \
|
||||
--ask-become-pass
|
||||
@@ -96,7 +103,8 @@ changes.
|
||||
After a successful bootstrap, verify the new account independently:
|
||||
|
||||
```bash
|
||||
../.venv/bin/ansible-playbook \
|
||||
../../.venv/bin/ansible-playbook \
|
||||
--inventory inventories/host/hosts.yml \
|
||||
playbooks/verify-deploy-access.yml \
|
||||
--extra-vars ansible_user=ansible-deploy \
|
||||
--private-key ~/.ssh/id_ed25519_ghostnet_ai_ansible_deploy
|
||||
@@ -121,18 +129,18 @@ It does not install software or start services. Use the dedicated host
|
||||
inventory and supply the private deployment key outside the repository:
|
||||
|
||||
```bash
|
||||
../.venv/bin/ansible-playbook \
|
||||
../../.venv/bin/ansible-playbook \
|
||||
--inventory inventories/host/hosts.yml \
|
||||
playbooks/host.yml \
|
||||
--syntax-check
|
||||
|
||||
../.venv/bin/ansible-playbook \
|
||||
../../.venv/bin/ansible-playbook \
|
||||
--inventory inventories/host/hosts.yml \
|
||||
playbooks/host.yml \
|
||||
--check --diff \
|
||||
--private-key ~/.ssh/id_ed25519_ghostnet_ai_ansible_deploy
|
||||
|
||||
../.venv/bin/ansible-playbook \
|
||||
../../.venv/bin/ansible-playbook \
|
||||
--inventory inventories/host/hosts.yml \
|
||||
playbooks/host.yml \
|
||||
--private-key ~/.ssh/id_ed25519_ghostnet_ai_ansible_deploy
|
||||
@@ -203,21 +211,21 @@ It passes `/dev/kfd` and both discovered render nodes with
|
||||
and `llama-server --list-devices` must expose exactly two ROCm devices. No
|
||||
architecture override is set.
|
||||
|
||||
Run the probe from the `ansible/` directory:
|
||||
Run the probe from `llm-server/ansible/`:
|
||||
|
||||
```bash
|
||||
../.venv/bin/ansible-playbook \
|
||||
../../.venv/bin/ansible-playbook \
|
||||
--inventory inventories/host/hosts.yml \
|
||||
playbooks/verify-rocm-container.yml \
|
||||
--syntax-check
|
||||
|
||||
../.venv/bin/ansible-playbook \
|
||||
../../.venv/bin/ansible-playbook \
|
||||
--inventory inventories/host/hosts.yml \
|
||||
playbooks/verify-rocm-container.yml \
|
||||
--check \
|
||||
--private-key ~/.ssh/id_ed25519_ghostnet_ai_ansible_deploy
|
||||
|
||||
../.venv/bin/ansible-playbook \
|
||||
../../.venv/bin/ansible-playbook \
|
||||
--inventory inventories/host/hosts.yml \
|
||||
playbooks/verify-rocm-container.yml \
|
||||
--private-key ~/.ssh/id_ed25519_ghostnet_ai_ansible_deploy
|
||||
@@ -1,11 +1,10 @@
|
||||
[defaults]
|
||||
inventory = inventories/testing/hosts.yml
|
||||
home = .ansible
|
||||
host_key_checking = True
|
||||
interpreter_python = auto_silent
|
||||
local_tmp = .ansible/tmp
|
||||
retry_files_enabled = False
|
||||
roles_path = roles
|
||||
roles_path = roles:../../shared/ansible/roles
|
||||
timeout = 15
|
||||
|
||||
[ssh_connection]
|
||||
@@ -0,0 +1,14 @@
|
||||
# CT 100 – Pi-hole DNS
|
||||
|
||||
Host root for the Pi-hole LXC at `10.0.0.14`.
|
||||
|
||||
The live service is stable and documented, but its original provisioning was
|
||||
not implemented as Ansible. Before any configuration change, import the
|
||||
observed Quadlet, nftables and SSH state into a CT-scoped Ansible project here,
|
||||
add a read-only verifier, and prove check-mode plus a second `changed=0` run.
|
||||
|
||||
Current review findings and acceptance evidence are documented in
|
||||
[`../../../docs/platform/proxmox-server/lxc/ct-100-dns/GNAI-012-PLAN_Pi-hole-DNS-Handoff.md`](../../../docs/platform/proxmox-server/lxc/ct-100-dns/GNAI-012-PLAN_Pi-hole-DNS-Handoff.md).
|
||||
|
||||
Do not store the Pi-hole web password, Podman secret contents, private SSH
|
||||
keys or DNS query data in this repository.
|
||||
@@ -0,0 +1,13 @@
|
||||
# CT 102 – Keycloak production
|
||||
|
||||
This is the host root for the production Keycloak LXC at `10.0.0.4`.
|
||||
|
||||
| Area | Content |
|
||||
| --- | --- |
|
||||
| [`ansible/`](ansible/README.md) | inventories, playbooks and CT-specific roles |
|
||||
| [`scripts/`](scripts/README.md) | reviewed PVE provisioning, bootstrap and cutover helpers |
|
||||
| [`../ct-104-keycloak/`](../ct-104-keycloak/README.md) | retained Blue/Green reference and rollback artifacts |
|
||||
| [`../../../docs/platform/proxmox-server/lxc/ct-102-keycloak/`](../../../docs/platform/proxmox-server/lxc/ct-102-keycloak/GNAI-016-PLAN_Keycloak-Ansible-Rebuild.md) | design, migration evidence and stabilization plan |
|
||||
|
||||
Private keys, database passwords, dumps and runtime secrets remain outside the
|
||||
repository.
|
||||
@@ -0,0 +1,37 @@
|
||||
# CT 102 Keycloak Ansible project
|
||||
|
||||
This self-contained host project manages the production Keycloak LXC. It uses
|
||||
the repository-wide pinned control dependency and the shared `podman_runtime`
|
||||
role, while all Keycloak inventories, playbooks, keys and service roles remain
|
||||
inside the CT 102 root.
|
||||
|
||||
Create the repository-level control environment once:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
.venv/bin/python -m pip install --upgrade pip
|
||||
.venv/bin/python -m pip install -r shared/ansible/requirements-control.txt
|
||||
```
|
||||
|
||||
Run commands from this directory and always select an inventory explicitly:
|
||||
|
||||
```bash
|
||||
cd proxmox/lxc/ct-102-keycloak/ansible
|
||||
../../../../.venv/bin/ansible-playbook \
|
||||
--inventory inventories/production/hosts.yml \
|
||||
playbooks/verify-production.yml \
|
||||
--private-key ~/.ssh/id_ed25519_ghostnet_ct102_ansible_deploy
|
||||
```
|
||||
|
||||
The production verifier is read-only. It checks the accepted restore,
|
||||
container health and hardening, database identity, restricted listeners,
|
||||
nftables, public OIDC, public admin denial and the internal admin path.
|
||||
|
||||
Use `playbooks/site.yml --check --diff` for a non-mutating drift preview. A
|
||||
real `site.yml` run enforces the declared state and therefore requires an
|
||||
explicit change authorization. `playbooks/restore.yml` is a one-shot recovery
|
||||
operation and must never be used as a routine convergence command.
|
||||
|
||||
The `reference` inventory addresses the retained CT 104 rollback instance;
|
||||
the `bootstrap` inventory is limited to initial account adoption. Secrets are
|
||||
generated on the target and are never accepted through inventory or Git.
|
||||
@@ -0,0 +1,12 @@
|
||||
[defaults]
|
||||
home = .ansible
|
||||
host_key_checking = True
|
||||
interpreter_python = auto_silent
|
||||
local_tmp = .ansible/tmp
|
||||
retry_files_enabled = False
|
||||
roles_path = roles:../../../../shared/ansible/roles
|
||||
timeout = 15
|
||||
|
||||
[ssh_connection]
|
||||
pipelining = True
|
||||
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICSWvCGDWzUGhXuK65EMZ78SilRdXDSG3ewg768qM3M1 ansible-deploy@ghostnet-ct102
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFe5g5D4VZnjhSvnZYJbvTOZKxzGby9QHcq2UAAUEBHw ghost-agent@keycloak.ghostnet
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIuVcr31VDjIHeD+GdDsHhM1zU3xH5zw4XODMeLu5Ctw ghost@keycloak.ghostnet
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
ansible_become_method: sudo
|
||||
ansible_python_interpreter: /usr/bin/python3
|
||||
ansible_ssh_common_args: >-
|
||||
-o IdentitiesOnly=yes
|
||||
-o StrictHostKeyChecking=yes
|
||||
-o UserKnownHostsFile=~/.ssh/known_hosts_ghostnet_ct102
|
||||
|
||||
keycloak_expected_distribution: Debian
|
||||
keycloak_expected_distribution_major_version: "13"
|
||||
keycloak_expected_hostname: keycloak
|
||||
keycloak_expected_virtualization_type: lxc
|
||||
keycloak_expected_podman_version: 5.4.2
|
||||
keycloak_minimum_root_size_bytes: 15000000000
|
||||
keycloak_minimum_root_available_bytes: 8000000000
|
||||
|
||||
keycloak_service_start_allowed: false
|
||||
keycloak_service_production_enabled: false
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
keycloak_ctid: 102
|
||||
keycloak_expected_ipv4: 10.0.0.4
|
||||
keycloak_expected_ssh_host_key_fingerprint: >-
|
||||
SHA256:L5tGPWFW+9uQKNQaAmi6HA9eXBKumibzRDt+2QYZfzQ
|
||||
keycloak_expected_processor_vcpus: 2
|
||||
keycloak_minimum_memory_mb: 3800
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
all:
|
||||
children:
|
||||
keycloak_hosts:
|
||||
hosts:
|
||||
keycloak-ct102:
|
||||
ansible_host: 10.0.0.4
|
||||
ansible_user: ghost
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
---
|
||||
ansible_become_method: sudo
|
||||
ansible_python_interpreter: /usr/bin/python3
|
||||
ansible_ssh_common_args: >-
|
||||
-o IdentitiesOnly=yes
|
||||
-o StrictHostKeyChecking=yes
|
||||
-o UserKnownHostsFile=~/.ssh/known_hosts_ghostnet_ct102
|
||||
|
||||
keycloak_expected_distribution: Debian
|
||||
keycloak_expected_distribution_major_version: "13"
|
||||
keycloak_expected_hostname: keycloak
|
||||
keycloak_expected_virtualization_type: lxc
|
||||
keycloak_expected_podman_version: 5.4.2
|
||||
keycloak_minimum_root_size_bytes: 15000000000
|
||||
keycloak_minimum_root_available_bytes: 8000000000
|
||||
|
||||
keycloak_firewall_management_ipv4:
|
||||
- 10.0.0.0/24
|
||||
- 10.8.0.0/24
|
||||
keycloak_firewall_reverse_proxy_ipv4:
|
||||
- 10.0.0.10
|
||||
keycloak_firewall_podman_subnet: 10.89.0.0/24
|
||||
keycloak_firewall_podman_gateway: 10.89.0.1
|
||||
keycloak_firewall_keycloak_ipv4: 10.89.0.20
|
||||
|
||||
keycloak_service_start_allowed: true
|
||||
keycloak_service_production_enabled: true
|
||||
|
||||
keycloak_restore_expected_public_objects: 88
|
||||
keycloak_restore_expected_liquibase_rows: 152
|
||||
keycloak_restore_expected_realm_summaries:
|
||||
- realm=Ghost.net|enabled=true|users=3|clients=9
|
||||
- realm=master|enabled=true|users=1|clients=8
|
||||
- realm=outline|enabled=true|users=1|clients=7
|
||||
keycloak_restore_accepted_dump_path: >-
|
||||
/var/backups/ghostnet/keycloak/keycloak-final-ct104-20261013T170621.dump
|
||||
keycloak_restore_accepted_dump_sha256: >-
|
||||
31b2337756b0ca6e2f3fc00a6b2deff6ba735f750363608059c4e6e6b0e51ed7
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
keycloak_ctid: 102
|
||||
keycloak_expected_ipv4: 10.0.0.4
|
||||
keycloak_expected_ssh_host_key_fingerprint: >-
|
||||
SHA256:L5tGPWFW+9uQKNQaAmi6HA9eXBKumibzRDt+2QYZfzQ
|
||||
keycloak_expected_processor_vcpus: 2
|
||||
keycloak_minimum_memory_mb: 3800
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
all:
|
||||
children:
|
||||
keycloak_hosts:
|
||||
hosts:
|
||||
keycloak-ct102:
|
||||
ansible_host: 10.0.0.4
|
||||
ansible_user: ansible-deploy
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
ansible_python_interpreter: /usr/bin/python3
|
||||
ansible_ssh_common_args: >-
|
||||
-o IdentitiesOnly=yes
|
||||
-o StrictHostKeyChecking=yes
|
||||
-o UserKnownHostsFile=~/.ssh/known_hosts_ghostnet_ct104
|
||||
|
||||
keycloak_reference_expected_distribution: Debian
|
||||
keycloak_reference_expected_distribution_major_version: "13"
|
||||
keycloak_reference_expected_hostname: keycloak
|
||||
keycloak_reference_expected_virtualization_type: lxc
|
||||
|
||||
keycloak_reference_units:
|
||||
- nftables.service
|
||||
- ghostnet-keycloak-network.service
|
||||
- ghostnet-keycloak-postgres-volume.service
|
||||
- ghostnet-keycloak-postgres.service
|
||||
- ghostnet-keycloak.service
|
||||
|
||||
keycloak_reference_readable_artifacts:
|
||||
- path: /etc/containers/systemd/ghostnet-keycloak.network
|
||||
sha256: bd383abc979327db0b262f1194bd5db71b56e2a5dba92bf41cebfa1d5f17961a
|
||||
- path: /etc/containers/systemd/ghostnet-keycloak-postgres.volume
|
||||
sha256: 5020dc799ce10165a1f902564af131654bb5c341bdfbc8c09a3ae8cc06023f70
|
||||
- path: /etc/containers/systemd/ghostnet-keycloak-postgres.container
|
||||
sha256: e8831810841d335110414401692829d13e7d8b8b29ab3ddfd30ad9befaced5a9
|
||||
- path: /etc/containers/systemd/ghostnet-keycloak.container
|
||||
sha256: b642976bb914e424000a54f1d59a8e2d1b8ba5b09650d23df846a16c6d04e1f2
|
||||
- path: /usr/local/sbin/ghostnet-wait-container-healthy
|
||||
sha256: a484bf4c167781379fe7aafe9ed9a6ea00808d53aeda0862e06eb0c7eedd0bce
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
keycloak_reference_ctid: 104
|
||||
keycloak_reference_expected_ipv4: 10.0.0.16
|
||||
keycloak_reference_expected_ssh_host_key_fingerprint: >-
|
||||
SHA256:AmLKE4H/ulGBQqC/J7mGO7rcu5U/xHtyKk45hhBuNRw
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
all:
|
||||
children:
|
||||
keycloak_reference_hosts:
|
||||
hosts:
|
||||
keycloak-ct104:
|
||||
ansible_host: 10.0.0.16
|
||||
ansible_user: ghost-agent
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
- name: Audit the production CT 104 Keycloak reference without privileges
|
||||
hosts: keycloak_reference_hosts
|
||||
become: false
|
||||
gather_facts: true
|
||||
gather_subset:
|
||||
- min
|
||||
- network
|
||||
- virtual
|
||||
|
||||
tasks:
|
||||
- name: Verify the production reference identity
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- inventory_hostname == "keycloak-ct104"
|
||||
- keycloak_reference_ctid | int == 104
|
||||
- ansible_facts["hostname"] == keycloak_reference_expected_hostname
|
||||
- >-
|
||||
ansible_facts["distribution"] ==
|
||||
keycloak_reference_expected_distribution
|
||||
- >-
|
||||
ansible_facts["distribution_major_version"] ==
|
||||
keycloak_reference_expected_distribution_major_version
|
||||
- >-
|
||||
ansible_facts["default_ipv4"]["address"] ==
|
||||
keycloak_reference_expected_ipv4
|
||||
- >-
|
||||
ansible_facts["virtualization_type"] ==
|
||||
keycloak_reference_expected_virtualization_type
|
||||
- ansible_facts["virtualization_role"] == "guest"
|
||||
fail_msg: >-
|
||||
The connected system is not the documented CT 104 reference.
|
||||
|
||||
- name: Read the audit account identity
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/id
|
||||
- -un
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_reference_audit_identity
|
||||
|
||||
- name: Verify the unprivileged audit identity
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_reference_audit_identity.stdout == "ghost-agent"
|
||||
fail_msg: The reference audit must use ghost-agent without sudo.
|
||||
|
||||
- name: Read documented account identities
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/id
|
||||
- "{{ item }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
loop:
|
||||
- ghost-agent
|
||||
- ghost
|
||||
register: keycloak_reference_accounts
|
||||
|
||||
- name: Read production unit states
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/systemctl
|
||||
- is-active
|
||||
- "{{ item }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
loop: "{{ keycloak_reference_units }}"
|
||||
register: keycloak_reference_unit_states
|
||||
|
||||
- name: Verify every production unit is active
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_reference_unit_states.results |
|
||||
map(attribute="stdout") |
|
||||
unique |
|
||||
list == ["active"]
|
||||
fail_msg: One or more documented CT 104 production units are inactive.
|
||||
|
||||
- name: Read failed systemd units
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/systemctl
|
||||
- --failed
|
||||
- --no-legend
|
||||
- --plain
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_reference_failed_units
|
||||
|
||||
- name: Verify no systemd unit is failed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_reference_failed_units.stdout | trim == ""
|
||||
|
||||
- name: Check readable reference artifacts
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item.path }}"
|
||||
checksum_algorithm: sha256
|
||||
get_checksum: true
|
||||
loop: "{{ keycloak_reference_readable_artifacts }}"
|
||||
register: keycloak_reference_artifact_stats
|
||||
|
||||
- name: Verify readable artifacts match the repository
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item.stat.exists
|
||||
- item.stat.isreg
|
||||
- item.stat.checksum == item.item.sha256
|
||||
fail_msg: >-
|
||||
{{ item.item.path }} differs from the reviewed repository artifact.
|
||||
loop: "{{ keycloak_reference_artifact_stats.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item.path }}"
|
||||
|
||||
- name: Read TCP listeners
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/ss
|
||||
- -H
|
||||
- -lnt
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_reference_tcp_listeners
|
||||
|
||||
- name: Verify the published Keycloak listener
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_reference_tcp_listeners.stdout is
|
||||
search("10\\.0\\.0\\.16:8080")
|
||||
- >-
|
||||
keycloak_reference_tcp_listeners.stdout is not
|
||||
search("(?:^|\\s)(?:0\\.0\\.0\\.0|\\*):9000")
|
||||
fail_msg: >-
|
||||
CT 104 does not expose the documented application/management ports.
|
||||
|
||||
- name: Report the unprivileged reference audit
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
hostname: "{{ ansible_facts['hostname'] }}"
|
||||
operating_system: >-
|
||||
{{ ansible_facts['distribution'] }}
|
||||
{{ ansible_facts['distribution_version'] }}
|
||||
default_ipv4: "{{ ansible_facts['default_ipv4']['address'] }}"
|
||||
audit_identity: "{{ keycloak_reference_audit_identity.stdout }}"
|
||||
active_units: "{{ keycloak_reference_units }}"
|
||||
checked_artifacts: >-
|
||||
{{ keycloak_reference_readable_artifacts |
|
||||
map(attribute='path') | list }}
|
||||
trusted_ssh_fingerprint: >-
|
||||
{{ keycloak_reference_expected_ssh_host_key_fingerprint }}
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
- name: Adopt the CT 102 access bootstrap declaratively
|
||||
hosts: keycloak_hosts
|
||||
become: true
|
||||
gather_facts: true
|
||||
gather_subset:
|
||||
- min
|
||||
- network
|
||||
- virtual
|
||||
|
||||
roles:
|
||||
- role: keycloak_access
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
- name: Configure the CT 102 Keycloak LXC foundation without workloads
|
||||
hosts: keycloak_hosts
|
||||
become: true
|
||||
gather_facts: true
|
||||
gather_subset:
|
||||
- min
|
||||
- hardware
|
||||
- network
|
||||
- virtual
|
||||
|
||||
roles:
|
||||
- role: keycloak_lxc_baseline
|
||||
vars:
|
||||
keycloak_lxc_baseline_expected_hostname: >-
|
||||
{{ keycloak_expected_hostname }}
|
||||
keycloak_lxc_baseline_expected_distribution: >-
|
||||
{{ keycloak_expected_distribution }}
|
||||
keycloak_lxc_baseline_expected_distribution_major_version: >-
|
||||
{{ keycloak_expected_distribution_major_version }}
|
||||
keycloak_lxc_baseline_expected_ipv4: "{{ keycloak_expected_ipv4 }}"
|
||||
keycloak_lxc_baseline_expected_virtualization_type: >-
|
||||
{{ keycloak_expected_virtualization_type }}
|
||||
- role: keycloak_access
|
||||
- role: podman_runtime
|
||||
- role: keycloak_firewall
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
- name: Verify the empty Keycloak LXC before configuration
|
||||
hosts: keycloak_hosts
|
||||
become: false
|
||||
gather_facts: true
|
||||
gather_subset:
|
||||
- min
|
||||
- hardware
|
||||
- network
|
||||
- virtual
|
||||
|
||||
tasks:
|
||||
- name: Verify required inventory safeguards are defined
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_ctid is defined
|
||||
- keycloak_expected_hostname is defined
|
||||
- keycloak_expected_ipv4 is defined
|
||||
- keycloak_expected_ssh_host_key_fingerprint is defined
|
||||
- keycloak_expected_distribution is defined
|
||||
- keycloak_expected_distribution_major_version is defined
|
||||
- keycloak_expected_virtualization_type is defined
|
||||
- keycloak_expected_podman_version is defined
|
||||
- keycloak_minimum_root_size_bytes is defined
|
||||
- keycloak_minimum_root_available_bytes is defined
|
||||
fail_msg: >-
|
||||
The selected inventory lacks mandatory Keycloak identity safeguards.
|
||||
Stop before running any configuration playbook.
|
||||
|
||||
- name: Verify the expected guest identity
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- inventory_hostname == "keycloak-ct102"
|
||||
- keycloak_ctid | int == 102
|
||||
- ansible_facts["hostname"] == keycloak_expected_hostname
|
||||
- ansible_facts["distribution"] == keycloak_expected_distribution
|
||||
- >-
|
||||
ansible_facts["distribution_major_version"] ==
|
||||
keycloak_expected_distribution_major_version
|
||||
- >-
|
||||
ansible_facts["default_ipv4"]["address"] ==
|
||||
keycloak_expected_ipv4
|
||||
- >-
|
||||
ansible_facts["virtualization_type"] ==
|
||||
keycloak_expected_virtualization_type
|
||||
- ansible_facts["virtualization_role"] == "guest"
|
||||
- ansible_facts["service_mgr"] == "systemd"
|
||||
- ansible_facts["python"]["version"]["major"] | int >= 3
|
||||
fail_msg: >-
|
||||
The connected machine is not the expected empty CT 102 Keycloak LXC.
|
||||
Stop before using any privileged playbook.
|
||||
success_msg: The expected Debian 13 Keycloak LXC is connected.
|
||||
|
||||
- name: Read the unprivileged SSH identity
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/id
|
||||
- -un
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_preflight_ssh_identity
|
||||
|
||||
- name: Verify the deployment account was used
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_preflight_ssh_identity.stdout == "ansible-deploy"
|
||||
fail_msg: >-
|
||||
The production inventory must connect through ansible-deploy.
|
||||
|
||||
- name: Read cgroup v2 controllers
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/cat
|
||||
- /sys/fs/cgroup/cgroup.controllers
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_preflight_cgroup_controllers
|
||||
|
||||
- name: Read the effective cgroup CPU limit
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/cat
|
||||
- /sys/fs/cgroup/cpu.max
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_preflight_cpu_max
|
||||
|
||||
- name: Read the effective cgroup memory limit
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/cat
|
||||
- /sys/fs/cgroup/memory.max
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_preflight_memory_max
|
||||
|
||||
- name: Read the effective processor count
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/nproc
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_preflight_nproc
|
||||
|
||||
- name: Read the Podman version
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- --version
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_preflight_podman_version
|
||||
|
||||
- name: Read root filesystem capacity
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/findmnt
|
||||
- --bytes
|
||||
- --noheadings
|
||||
- --output
|
||||
- SIZE,AVAIL
|
||||
- --target
|
||||
- /
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_preflight_root_capacity
|
||||
|
||||
- name: Read failed systemd units
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/systemctl
|
||||
- --failed
|
||||
- --no-legend
|
||||
- --plain
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_preflight_failed_units
|
||||
|
||||
- name: Read reserved service listeners
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/ss
|
||||
- -H
|
||||
- -lnt
|
||||
- ( sport = :5432 or sport = :8080 or sport = :9000 )
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_preflight_reserved_listeners
|
||||
|
||||
- name: Read rootful Podman containers
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- ps
|
||||
- --all
|
||||
- --quiet
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_preflight_root_containers
|
||||
|
||||
- name: Read rootful Podman images
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- images
|
||||
- --quiet
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_preflight_root_images
|
||||
|
||||
- name: Verify the empty-LXC commissioning gates
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_preflight_cgroup_controllers.stdout | length > 0
|
||||
- >-
|
||||
keycloak_preflight_nproc.stdout | int ==
|
||||
keycloak_expected_processor_vcpus | int
|
||||
- >-
|
||||
ansible_facts["memtotal_mb"] | int >=
|
||||
keycloak_minimum_memory_mb | int
|
||||
- >-
|
||||
keycloak_preflight_podman_version.stdout ==
|
||||
"podman version " ~ keycloak_expected_podman_version
|
||||
- >-
|
||||
keycloak_preflight_root_capacity.stdout.split()[0] | int >=
|
||||
keycloak_minimum_root_size_bytes | int
|
||||
- >-
|
||||
keycloak_preflight_root_capacity.stdout.split()[1] | int >=
|
||||
keycloak_minimum_root_available_bytes | int
|
||||
- keycloak_preflight_failed_units.stdout | trim == ""
|
||||
- keycloak_preflight_reserved_listeners.stdout | trim == ""
|
||||
- keycloak_preflight_root_containers.stdout | trim == ""
|
||||
- keycloak_preflight_root_images.stdout | trim == ""
|
||||
fail_msg: >-
|
||||
The target is not the expected healthy and empty Podman LXC. Stop
|
||||
before creating secrets, networks, volumes or application services.
|
||||
success_msg: >-
|
||||
CT 102 is healthy, empty and ready for declarative configuration.
|
||||
|
||||
- name: Report the verified Keycloak commissioning baseline
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
inventory_host: "{{ inventory_hostname }}"
|
||||
ctid: "{{ keycloak_ctid }}"
|
||||
hostname: "{{ ansible_facts['hostname'] }}"
|
||||
operating_system: >-
|
||||
{{ ansible_facts['distribution'] }}
|
||||
{{ ansible_facts['distribution_version'] }}
|
||||
default_ipv4: "{{ ansible_facts['default_ipv4']['address'] }}"
|
||||
virtualization: >-
|
||||
{{ ansible_facts['virtualization_type'] }}
|
||||
{{ ansible_facts['virtualization_role'] }}
|
||||
host_visible_processor_vcpus: >-
|
||||
{{ ansible_facts['processor_vcpus'] }}
|
||||
effective_processor_count: "{{ keycloak_preflight_nproc.stdout }}"
|
||||
memory_mib: "{{ ansible_facts['memtotal_mb'] }}"
|
||||
cgroup_cpu_max: "{{ keycloak_preflight_cpu_max.stdout }}"
|
||||
cgroup_memory_max: "{{ keycloak_preflight_memory_max.stdout }}"
|
||||
podman_version: "{{ keycloak_preflight_podman_version.stdout }}"
|
||||
root_size_bytes: >-
|
||||
{{ keycloak_preflight_root_capacity.stdout.split()[0] }}
|
||||
root_available_bytes: >-
|
||||
{{ keycloak_preflight_root_capacity.stdout.split()[1] }}
|
||||
trusted_ssh_fingerprint: >-
|
||||
{{ keycloak_expected_ssh_host_key_fingerprint }}
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
- name: Restore a reviewed Keycloak PostgreSQL dump into empty CT 102
|
||||
hosts: keycloak_hosts
|
||||
become: true
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- ../roles/keycloak_postgres/defaults/main.yml
|
||||
- ../roles/keycloak_service/defaults/main.yml
|
||||
vars:
|
||||
keycloak_restore_confirmation_phrase: RESTORE_EMPTY_CT102_KEYCLOAK_DATABASE
|
||||
|
||||
tasks:
|
||||
- name: Require explicit reviewed restore inputs
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_restore_dump_path is defined
|
||||
- keycloak_restore_dump_sha256 is defined
|
||||
- keycloak_restore_confirmation is defined
|
||||
- >-
|
||||
keycloak_restore_dump_path | default('') is
|
||||
match('^/var/backups/ghostnet/keycloak/[A-Za-z0-9._-]+\\.dump$')
|
||||
- >-
|
||||
keycloak_restore_dump_sha256 | default('') is
|
||||
match('^[0-9a-f]{64}$')
|
||||
- >-
|
||||
keycloak_restore_confirmation | default('') ==
|
||||
keycloak_restore_confirmation_phrase
|
||||
fail_msg: >-
|
||||
Supply the root-only CT 102 dump path, its reviewed SHA-256 and the
|
||||
exact restore confirmation phrase. No database change was made.
|
||||
|
||||
- name: Inspect the protected restore archive
|
||||
ansible.builtin.stat:
|
||||
path: "{{ keycloak_restore_dump_path }}"
|
||||
checksum_algorithm: sha256
|
||||
get_checksum: true
|
||||
register: keycloak_restore_dump
|
||||
|
||||
- name: Verify restore archive identity and protection
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_restore_dump.stat.exists
|
||||
- keycloak_restore_dump.stat.isreg
|
||||
- keycloak_restore_dump.stat.size | int > 0
|
||||
- keycloak_restore_dump.stat.mode == "0600"
|
||||
- keycloak_restore_dump.stat.pw_name == "root"
|
||||
- keycloak_restore_dump.stat.gr_name == "root"
|
||||
- >-
|
||||
keycloak_restore_dump.stat.checksum ==
|
||||
keycloak_restore_dump_sha256
|
||||
fail_msg: >-
|
||||
The restore archive is missing, insufficiently protected or differs
|
||||
from its reviewed SHA-256 checksum.
|
||||
|
||||
- name: Read application and database service states
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/systemctl
|
||||
- is-active
|
||||
- "{{ item }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
loop:
|
||||
- "{{ keycloak_postgres_service_unit }}"
|
||||
- ghostnet-keycloak.service
|
||||
register: keycloak_restore_service_states
|
||||
|
||||
- name: Require healthy database dependency and stopped Keycloak
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_restore_service_states.results[0].stdout == "active"
|
||||
- >-
|
||||
keycloak_restore_service_states.results[1].stdout in
|
||||
["inactive", "failed"]
|
||||
|
||||
- name: Inspect the closed start gate
|
||||
ansible.builtin.stat:
|
||||
path: "{{ keycloak_service_start_gate }}"
|
||||
register: keycloak_restore_start_gate
|
||||
|
||||
- name: Require the application start gate to remain closed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not keycloak_restore_start_gate.stat.exists
|
||||
|
||||
- name: Inspect the PostgreSQL container health
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- inspect
|
||||
- --format=json
|
||||
- "{{ keycloak_postgres_container_name }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_restore_postgres_inspect
|
||||
|
||||
- name: Require a healthy PostgreSQL restore target
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
(keycloak_restore_postgres_inspect.stdout |
|
||||
from_json)[0].State.Status == "running"
|
||||
- >-
|
||||
(keycloak_restore_postgres_inspect.stdout |
|
||||
from_json)[0].State.Health.Status == "healthy"
|
||||
|
||||
- name: Verify the custom dump archive catalog
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
/usr/bin/podman exec -i
|
||||
{{ keycloak_postgres_container_name | quote }}
|
||||
/usr/bin/pg_restore --list
|
||||
< {{ keycloak_restore_dump_path | quote }}
|
||||
executable: /bin/bash
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_restore_catalog
|
||||
|
||||
- name: Require expected Keycloak schema content in the archive
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_restore_catalog.stdout |
|
||||
regex_search('TABLE DATA public realm ') is not none
|
||||
- >-
|
||||
keycloak_restore_catalog.stdout |
|
||||
regex_search('TABLE DATA public user_entity ') is not none
|
||||
- >-
|
||||
keycloak_restore_catalog.stdout |
|
||||
regex_search('TABLE DATA public client ') is not none
|
||||
- >-
|
||||
keycloak_restore_catalog.stdout |
|
||||
regex_search('TABLE DATA public databasechangelog ') is not none
|
||||
fail_msg: The archive catalog is not the expected Keycloak dump.
|
||||
|
||||
- name: Read pre-restore application object count
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- exec
|
||||
- "{{ keycloak_postgres_container_name }}"
|
||||
- /bin/sh
|
||||
- -eu
|
||||
- -c
|
||||
- >-
|
||||
PGPASSWORD="$(cat /run/secrets/keycloak-db-password)";
|
||||
export PGPASSWORD;
|
||||
exec psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U keycloak
|
||||
-d keycloak -At -c "select count(*) from pg_class c join
|
||||
pg_namespace n on n.oid=c.relnamespace where n.nspname='public'
|
||||
and c.relkind in ('r','p','v','m','S','f');"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_restore_pre_object_count
|
||||
|
||||
- name: Refuse restore into a non-empty application schema
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_restore_pre_object_count.stdout == "0"
|
||||
fail_msg: >-
|
||||
The target public schema is not empty. This playbook will not clean,
|
||||
overwrite or merge an existing database.
|
||||
|
||||
- name: Restore the Keycloak archive in one database transaction
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
/usr/bin/podman exec -i
|
||||
{{ keycloak_postgres_container_name | quote }}
|
||||
/bin/sh -eu -c
|
||||
'PGPASSWORD="$(cat /run/secrets/keycloak-db-password)";
|
||||
export PGPASSWORD;
|
||||
exec pg_restore --exit-on-error --single-transaction --no-owner
|
||||
--no-acl --host=127.0.0.1 --username=keycloak
|
||||
--dbname=keycloak'
|
||||
< {{ keycloak_restore_dump_path | quote }}
|
||||
executable: /bin/bash
|
||||
changed_when: true
|
||||
|
||||
- name: Read restored Keycloak database summary
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- exec
|
||||
- "{{ keycloak_postgres_container_name }}"
|
||||
- /bin/sh
|
||||
- -eu
|
||||
- -c
|
||||
- >-
|
||||
PGPASSWORD="$(cat /run/secrets/keycloak-db-password)";
|
||||
export PGPASSWORD;
|
||||
exec psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U keycloak
|
||||
-d keycloak -At
|
||||
-c "select 'public_objects=' || count(*) from pg_class c join
|
||||
pg_namespace n on n.oid=c.relnamespace where n.nspname='public'
|
||||
and c.relkind in ('r','p','v','m','S','f');"
|
||||
-c "select 'realm=' || r.name || '|enabled=' || r.enabled ||
|
||||
'|users=' || (select count(*) from user_entity u where
|
||||
u.realm_id=r.id) || '|clients=' || (select count(*) from client c
|
||||
where c.realm_id=r.id) from realm r order by r.name;"
|
||||
-c "select 'liquibase_rows=' || count(*) from
|
||||
databasechangelog;"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_restore_database_summary
|
||||
|
||||
- name: Verify restored schema and identity model
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
'public_objects=' ~
|
||||
(keycloak_restore_expected_public_objects | string) in
|
||||
keycloak_restore_database_summary.stdout_lines
|
||||
- >-
|
||||
'liquibase_rows=' ~
|
||||
(keycloak_restore_expected_liquibase_rows | string) in
|
||||
keycloak_restore_database_summary.stdout_lines
|
||||
- >-
|
||||
keycloak_restore_expected_realm_summaries |
|
||||
difference(keycloak_restore_database_summary.stdout_lines) |
|
||||
length == 0
|
||||
fail_msg: >-
|
||||
The transaction completed but the restored schema summary differs
|
||||
from the reviewed reference. Keep the application gate closed.
|
||||
|
||||
- name: Record the accepted restore archive identity
|
||||
ansible.builtin.copy:
|
||||
content: |
|
||||
source_dump={{ keycloak_restore_dump_path }}
|
||||
source_dump_sha256={{ keycloak_restore_dump_sha256 }}
|
||||
dest: /var/backups/ghostnet/keycloak/RESTORED_FROM
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
|
||||
- name: Confirm the application remains gated after restore
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/systemctl
|
||||
- is-active
|
||||
- ghostnet-keycloak.service
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
register: keycloak_restore_final_application_state
|
||||
|
||||
- name: Require Keycloak to remain stopped after database restore
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_restore_final_application_state.stdout in
|
||||
["inactive", "failed"]
|
||||
success_msg: >-
|
||||
The reviewed dump was restored and validated; Keycloak remains
|
||||
stopped behind its explicit start gate.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
- name: Configure the CT 102 Keycloak LXC foundation
|
||||
hosts: keycloak_hosts
|
||||
become: true
|
||||
gather_facts: true
|
||||
gather_subset:
|
||||
- min
|
||||
- hardware
|
||||
- network
|
||||
- virtual
|
||||
|
||||
roles:
|
||||
- role: keycloak_lxc_baseline
|
||||
vars:
|
||||
keycloak_lxc_baseline_expected_hostname: >-
|
||||
{{ keycloak_expected_hostname }}
|
||||
keycloak_lxc_baseline_expected_distribution: >-
|
||||
{{ keycloak_expected_distribution }}
|
||||
keycloak_lxc_baseline_expected_distribution_major_version: >-
|
||||
{{ keycloak_expected_distribution_major_version }}
|
||||
keycloak_lxc_baseline_expected_ipv4: "{{ keycloak_expected_ipv4 }}"
|
||||
keycloak_lxc_baseline_expected_virtualization_type: >-
|
||||
{{ keycloak_expected_virtualization_type }}
|
||||
- role: keycloak_access
|
||||
- role: podman_runtime
|
||||
- role: keycloak_firewall
|
||||
- role: keycloak_postgres
|
||||
- role: keycloak_service
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
- name: Verify the gated CT 102 Keycloak commissioning state
|
||||
hosts: keycloak_hosts
|
||||
become: true
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- ../roles/keycloak_postgres/defaults/main.yml
|
||||
- ../roles/keycloak_service/defaults/main.yml
|
||||
|
||||
tasks:
|
||||
- name: Read required systemd service states
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/systemctl
|
||||
- is-active
|
||||
- "{{ item }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
loop:
|
||||
- nftables.service
|
||||
- "{{ keycloak_postgres_service_unit }}"
|
||||
- ghostnet-keycloak.service
|
||||
register: keycloak_commissioning_service_states
|
||||
|
||||
- name: Require active dependencies and a stopped Keycloak application
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_commissioning_service_states.results[0].stdout == "active"
|
||||
- keycloak_commissioning_service_states.results[1].stdout == "active"
|
||||
- >-
|
||||
keycloak_commissioning_service_states.results[2].stdout in
|
||||
["inactive", "failed"]
|
||||
fail_msg: >-
|
||||
The host is not in the expected gated commissioning state.
|
||||
|
||||
- name: Inspect commissioning gate and generated boot selection
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item }}"
|
||||
follow: false
|
||||
loop:
|
||||
- "{{ keycloak_service_start_gate }}"
|
||||
- >-
|
||||
/run/systemd/generator/multi-user.target.wants/ghostnet-keycloak.service
|
||||
register: keycloak_commissioning_gate_files
|
||||
|
||||
- name: Require the application start gate and boot link to remain absent
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- not keycloak_commissioning_gate_files.results[0].stat.exists
|
||||
- not keycloak_commissioning_gate_files.results[1].stat.exists
|
||||
|
||||
- name: Inspect protected secret source metadata
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item }}"
|
||||
loop:
|
||||
- "{{ keycloak_postgres_admin_secret_file }}"
|
||||
- "{{ keycloak_postgres_app_secret_file }}"
|
||||
register: keycloak_commissioning_secret_files
|
||||
|
||||
- name: Verify protected secret source invariants
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item.stat.exists
|
||||
- item.stat.isreg
|
||||
- item.stat.size == 64
|
||||
- item.stat.mode == "0600"
|
||||
- item.stat.pw_name == "root"
|
||||
- item.stat.gr_name == "root"
|
||||
fail_msg: A PostgreSQL secret source violates its metadata invariant.
|
||||
no_log: true
|
||||
loop: "{{ keycloak_commissioning_secret_files.results }}"
|
||||
|
||||
- name: Verify the read-only agent cannot cross privileged boundaries
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/sbin/runuser
|
||||
- -u
|
||||
- ghost-agent
|
||||
- --
|
||||
- /bin/sh
|
||||
- -eu
|
||||
- -c
|
||||
- >-
|
||||
test ! -r /etc/ghostnet/keycloak/secrets/postgres-password;
|
||||
test ! -w /etc/containers/systemd;
|
||||
test ! -w /var/backups/ghostnet/keycloak;
|
||||
! /usr/bin/sudo -n /usr/bin/true >/dev/null 2>&1
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
|
||||
- name: Read host listeners reserved for Keycloak
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/ss
|
||||
- -H
|
||||
- -lnt
|
||||
- ( sport = :5432 or sport = :8080 or sport = :9000 )
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_commissioning_listeners
|
||||
|
||||
- name: Require no database or application host listeners before restore
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_commissioning_listeners.stdout | trim == ""
|
||||
|
||||
- name: Inspect the PostgreSQL container
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- inspect
|
||||
- --format=json
|
||||
- "{{ keycloak_postgres_container_name }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_commissioning_postgres_inspect
|
||||
|
||||
- name: Require a healthy, unprivileged PostgreSQL container
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
(keycloak_commissioning_postgres_inspect.stdout |
|
||||
from_json)[0].State.Status == "running"
|
||||
- >-
|
||||
(keycloak_commissioning_postgres_inspect.stdout |
|
||||
from_json)[0].State.Health.Status == "healthy"
|
||||
- >-
|
||||
(keycloak_commissioning_postgres_inspect.stdout |
|
||||
from_json)[0].Config.User == "999:999"
|
||||
|
||||
- name: Verify the empty restricted Keycloak database
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- exec
|
||||
- "{{ keycloak_postgres_container_name }}"
|
||||
- /bin/sh
|
||||
- -eu
|
||||
- -c
|
||||
- >-
|
||||
PGPASSWORD="$(cat /run/secrets/keycloak-db-password)";
|
||||
export PGPASSWORD;
|
||||
exec psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U keycloak
|
||||
-d keycloak -At -F '|'
|
||||
-c "select current_database(), current_user, pg_is_in_recovery();"
|
||||
-c "select count(*) from pg_class c join pg_namespace n on
|
||||
n.oid=c.relnamespace where n.nspname='public' and
|
||||
c.relkind in ('r','p','v','m','S','f');"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_commissioning_database
|
||||
|
||||
- name: Require the expected empty application-owned primary database
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_commissioning_database.stdout_lines ==
|
||||
["keycloak|keycloak|f", "0"]
|
||||
|
||||
- name: Inspect the optimized Keycloak runtime image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- image
|
||||
- inspect
|
||||
- --format=json
|
||||
- "{{ keycloak_service_runtime_tag }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_commissioning_runtime_image
|
||||
|
||||
- name: Select the inspected runtime image
|
||||
ansible.builtin.set_fact:
|
||||
keycloak_commissioning_runtime: >-
|
||||
{{ (keycloak_commissioning_runtime_image.stdout | from_json)[0] }}
|
||||
|
||||
- name: Verify the immutable optimized Keycloak runtime
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_commissioning_runtime.Architecture ==
|
||||
keycloak_service_expected_architecture
|
||||
- keycloak_commissioning_runtime.Os == "linux"
|
||||
- >-
|
||||
keycloak_commissioning_runtime.Config.Labels[
|
||||
'eu.selfhost.ghostnet.containerfile-sha256'] ==
|
||||
keycloak_service_containerfile_sha256
|
||||
- >-
|
||||
keycloak_commissioning_runtime.Id is
|
||||
match('^(sha256:)?[0-9a-f]{64}$')
|
||||
|
||||
- name: Read the generated application Quadlet
|
||||
ansible.builtin.slurp:
|
||||
path: /etc/containers/systemd/ghostnet-keycloak.container
|
||||
register: keycloak_commissioning_quadlet
|
||||
|
||||
- name: Verify the application Quadlet pins the runtime and host address
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
'Image=' ~ keycloak_commissioning_runtime.Id in
|
||||
(keycloak_commissioning_quadlet.content | b64decode)
|
||||
- >-
|
||||
'PublishPort=' ~ keycloak_expected_ipv4 ~ ':8080:8080/tcp' in
|
||||
(keycloak_commissioning_quadlet.content | b64decode)
|
||||
|
||||
- name: Read the managed nftables table
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/sbin/nft
|
||||
- list
|
||||
- table
|
||||
- inet
|
||||
- ghostnet_filter
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_commissioning_firewall
|
||||
|
||||
- name: Verify deny-by-default firewall boundaries are loaded
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'policy drop' in keycloak_commissioning_firewall.stdout"
|
||||
- "'10.0.0.10' in keycloak_commissioning_firewall.stdout"
|
||||
- "'tcp dport 8080' in keycloak_commissioning_firewall.stdout"
|
||||
- "'10.89.0.0/24' in keycloak_commissioning_firewall.stdout"
|
||||
|
||||
- name: Read failed systemd units
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/systemctl
|
||||
- --failed
|
||||
- --no-legend
|
||||
- --plain
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_commissioning_failed_units
|
||||
|
||||
- name: Require a healthy host commissioning state
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_commissioning_failed_units.stdout | trim == ""
|
||||
success_msg: >-
|
||||
CT 102 is reproducibly commissioned with PostgreSQL healthy,
|
||||
Keycloak gated and no restored application data.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
- name: Verify the CT 102 Ansible deployment boundary
|
||||
hosts: keycloak_hosts
|
||||
become: false
|
||||
gather_facts: false
|
||||
|
||||
tasks:
|
||||
- name: Verify Ansible module execution without privilege escalation
|
||||
ansible.builtin.ping:
|
||||
|
||||
- name: Read the SSH account identity
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/id
|
||||
- -un
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_deploy_account_identity
|
||||
|
||||
- name: Verify the expected SSH account
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_deploy_account_identity.stdout == "ansible-deploy"
|
||||
fail_msg: The SSH connection did not use ansible-deploy.
|
||||
|
||||
- name: Read the effective identity through sudo
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/id
|
||||
- -u
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_deploy_root_identity
|
||||
|
||||
- name: Verify non-interactive root escalation
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_deploy_root_identity.stdout == "0"
|
||||
fail_msg: Non-interactive sudo did not produce effective UID 0.
|
||||
success_msg: >-
|
||||
The CT 102 deployment account can connect and escalate privileges
|
||||
non-interactively.
|
||||
@@ -0,0 +1,365 @@
|
||||
---
|
||||
- name: Verify the production CT 102 Keycloak cutover
|
||||
hosts: keycloak_hosts
|
||||
become: true
|
||||
gather_facts: false
|
||||
vars:
|
||||
keycloak_production_start_gate: >-
|
||||
/etc/ghostnet/keycloak/allow-keycloak-start
|
||||
keycloak_production_application_container: ghostnet-keycloak
|
||||
keycloak_production_database_container: ghostnet-keycloak-postgres
|
||||
keycloak_production_postgres_service_unit: >-
|
||||
ghostnet-keycloak-postgres.service
|
||||
keycloak_production_postgres_image: >-
|
||||
docker.io/library/postgres@sha256:ec9f656f17c8b4dfebf49e58cf5c46d493d4846d4ea6d8b85d0637f68ee33bfc
|
||||
keycloak_production_containerfile_sha256: >-
|
||||
685209b713bdbceb08a75ecce14f886ae27ebf117c6f693c3bb06611f05e76f3
|
||||
|
||||
tasks:
|
||||
- name: Require an explicitly enabled production inventory
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_service_start_allowed | bool
|
||||
- keycloak_service_production_enabled | bool
|
||||
- keycloak_restore_accepted_dump_path is defined
|
||||
- >-
|
||||
keycloak_restore_accepted_dump_path is
|
||||
match('^/var/backups/ghostnet/keycloak/[A-Za-z0-9._-]+\.dump$')
|
||||
- >-
|
||||
keycloak_restore_accepted_dump_sha256 | default('') is
|
||||
match('^[0-9a-f]{64}$')
|
||||
fail_msg: >-
|
||||
Production verification requires the enabled service policy and the
|
||||
accepted cutover archive identity in the selected inventory.
|
||||
|
||||
- name: Read required production service states
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/systemctl
|
||||
- is-active
|
||||
- "{{ item }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
loop:
|
||||
- nftables.service
|
||||
- ghostnet-keycloak-network.service
|
||||
- ghostnet-keycloak-postgres-volume.service
|
||||
- "{{ keycloak_production_postgres_service_unit }}"
|
||||
- ghostnet-keycloak.service
|
||||
register: keycloak_production_service_states
|
||||
|
||||
- name: Require every production dependency to be active
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_production_service_states.results |
|
||||
map(attribute='stdout') | unique | list == ['active']
|
||||
|
||||
- name: Read the Keycloak systemd runtime policy
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/systemctl
|
||||
- show
|
||||
- ghostnet-keycloak.service
|
||||
- --property=ActiveState
|
||||
- --property=SubState
|
||||
- --property=Result
|
||||
- --property=Restart
|
||||
- --no-pager
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_production_systemd_policy
|
||||
|
||||
- name: Require the production restart policy and successful runtime state
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'ActiveState=active' in keycloak_production_systemd_policy.stdout_lines"
|
||||
- "'SubState=running' in keycloak_production_systemd_policy.stdout_lines"
|
||||
- "'Result=success' in keycloak_production_systemd_policy.stdout_lines"
|
||||
- "'Restart=on-failure' in keycloak_production_systemd_policy.stdout_lines"
|
||||
|
||||
- name: Inspect production gates and boot selection
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item }}"
|
||||
follow: false
|
||||
loop:
|
||||
- "{{ keycloak_production_start_gate }}"
|
||||
- >-
|
||||
/run/systemd/generator/multi-user.target.wants/{{ keycloak_production_postgres_service_unit }}
|
||||
- >-
|
||||
/run/systemd/generator/multi-user.target.wants/ghostnet-keycloak.service
|
||||
register: keycloak_production_boot_files
|
||||
|
||||
- name: Require the open start gate and generated boot links
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_production_boot_files.results[0].stat.exists
|
||||
- keycloak_production_boot_files.results[0].stat.isreg
|
||||
- keycloak_production_boot_files.results[0].stat.mode == '0600'
|
||||
- keycloak_production_boot_files.results[0].stat.pw_name == 'root'
|
||||
- keycloak_production_boot_files.results[0].stat.gr_name == 'root'
|
||||
- keycloak_production_boot_files.results[1].stat.exists
|
||||
- keycloak_production_boot_files.results[1].stat.islnk
|
||||
- keycloak_production_boot_files.results[2].stat.exists
|
||||
- keycloak_production_boot_files.results[2].stat.islnk
|
||||
|
||||
- name: Inspect the accepted restore archive
|
||||
ansible.builtin.stat:
|
||||
path: "{{ keycloak_restore_accepted_dump_path }}"
|
||||
checksum_algorithm: sha256
|
||||
get_checksum: true
|
||||
register: keycloak_production_restore_dump
|
||||
|
||||
- name: Verify the retained restore archive identity and protection
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_production_restore_dump.stat.exists
|
||||
- keycloak_production_restore_dump.stat.isreg
|
||||
- keycloak_production_restore_dump.stat.size | int > 0
|
||||
- keycloak_production_restore_dump.stat.mode == '0600'
|
||||
- keycloak_production_restore_dump.stat.pw_name == 'root'
|
||||
- keycloak_production_restore_dump.stat.gr_name == 'root'
|
||||
- >-
|
||||
keycloak_production_restore_dump.stat.checksum ==
|
||||
keycloak_restore_accepted_dump_sha256
|
||||
|
||||
- name: Read the accepted restore marker
|
||||
ansible.builtin.slurp:
|
||||
path: /var/backups/ghostnet/keycloak/RESTORED_FROM
|
||||
register: keycloak_production_restore_marker
|
||||
|
||||
- name: Verify the restore marker records the accepted archive
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
(keycloak_production_restore_marker.content |
|
||||
b64decode).splitlines() ==
|
||||
[
|
||||
'source_dump=' ~ keycloak_restore_accepted_dump_path,
|
||||
'source_dump_sha256=' ~ keycloak_restore_accepted_dump_sha256
|
||||
]
|
||||
|
||||
- name: Inspect the production containers
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- inspect
|
||||
- --format=json
|
||||
- "{{ keycloak_production_application_container }}"
|
||||
- "{{ keycloak_production_database_container }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_production_container_inspect
|
||||
|
||||
- name: Select the inspected production containers
|
||||
ansible.builtin.set_fact:
|
||||
keycloak_production_application: >-
|
||||
{{ (keycloak_production_container_inspect.stdout | from_json)[0] }}
|
||||
keycloak_production_database: >-
|
||||
{{ (keycloak_production_container_inspect.stdout | from_json)[1] }}
|
||||
|
||||
- name: Verify the hardened Keycloak application container
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_production_application.State.Status == 'running'
|
||||
- keycloak_production_application.State.Health.Status == 'healthy'
|
||||
- keycloak_production_application.Config.User == '1000:0'
|
||||
- keycloak_production_application.HostConfig.ReadonlyRootfs
|
||||
- not keycloak_production_application.HostConfig.Privileged
|
||||
- >-
|
||||
'no-new-privileges' in
|
||||
keycloak_production_application.HostConfig.SecurityOpt
|
||||
- >-
|
||||
keycloak_production_application.NetworkSettings.Networks[
|
||||
'ghostnet-keycloak'].IPAddress == keycloak_firewall_keycloak_ipv4
|
||||
- >-
|
||||
keycloak_production_application.NetworkSettings.Ports[
|
||||
'8080/tcp'][0].HostIp == keycloak_expected_ipv4
|
||||
- >-
|
||||
keycloak_production_application.NetworkSettings.Ports[
|
||||
'8080/tcp'][0].HostPort == '8080'
|
||||
- >-
|
||||
keycloak_production_application.Config.Labels[
|
||||
'eu.selfhost.ghostnet.containerfile-sha256'] ==
|
||||
keycloak_production_containerfile_sha256
|
||||
|
||||
- name: Verify the isolated PostgreSQL container
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_production_database.State.Status == 'running'
|
||||
- keycloak_production_database.State.Health.Status == 'healthy'
|
||||
- keycloak_production_database.Config.User == '999:999'
|
||||
- keycloak_production_database.HostConfig.ReadonlyRootfs
|
||||
- not keycloak_production_database.HostConfig.Privileged
|
||||
- >-
|
||||
'no-new-privileges' in
|
||||
keycloak_production_database.HostConfig.SecurityOpt
|
||||
- >-
|
||||
keycloak_production_database.NetworkSettings.Networks[
|
||||
'ghostnet-keycloak'].IPAddress == '10.89.0.10'
|
||||
- keycloak_production_database.HostConfig.PortBindings == {}
|
||||
- >-
|
||||
keycloak_production_database.ImageDigest ==
|
||||
keycloak_production_postgres_image.split('@')[1]
|
||||
|
||||
- name: Read restored Keycloak database summary
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- exec
|
||||
- "{{ keycloak_production_database_container }}"
|
||||
- /bin/sh
|
||||
- -eu
|
||||
- -c
|
||||
- >-
|
||||
PGPASSWORD="$(cat /run/secrets/keycloak-db-password)";
|
||||
export PGPASSWORD;
|
||||
exec psql -v ON_ERROR_STOP=1 -h 127.0.0.1 -U keycloak
|
||||
-d keycloak -At
|
||||
-c "select 'identity=' || current_database() || '|' ||
|
||||
current_user || '|recovery=' || pg_is_in_recovery();"
|
||||
-c "select 'public_objects=' || count(*) from pg_class c join
|
||||
pg_namespace n on n.oid=c.relnamespace where n.nspname='public'
|
||||
and c.relkind in ('r','p','v','m','S','f');"
|
||||
-c "select 'realm=' || r.name || '|enabled=' || r.enabled ||
|
||||
'|users=' || (select count(*) from user_entity u where
|
||||
u.realm_id=r.id) || '|clients=' || (select count(*) from client c
|
||||
where c.realm_id=r.id) from realm r order by r.name;"
|
||||
-c "select 'liquibase_rows=' || count(*) from
|
||||
databasechangelog;"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_production_database_summary
|
||||
|
||||
- name: Verify the restored production identity model
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
'identity=keycloak|keycloak|recovery=false' in
|
||||
keycloak_production_database_summary.stdout_lines
|
||||
- >-
|
||||
'public_objects=' ~
|
||||
(keycloak_restore_expected_public_objects | string) in
|
||||
keycloak_production_database_summary.stdout_lines
|
||||
- >-
|
||||
'liquibase_rows=' ~
|
||||
(keycloak_restore_expected_liquibase_rows | string) in
|
||||
keycloak_production_database_summary.stdout_lines
|
||||
- >-
|
||||
keycloak_restore_expected_realm_summaries |
|
||||
difference(keycloak_production_database_summary.stdout_lines) |
|
||||
length == 0
|
||||
|
||||
- name: Read reserved host listeners
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/ss
|
||||
- -H
|
||||
- -lnt
|
||||
- ( sport = :5432 or sport = :8080 or sport = :9000 )
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_production_listeners
|
||||
|
||||
- name: Require only the restricted Keycloak application listener
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_production_listeners.stdout is
|
||||
search('(?:^|\\s)10\\.0\\.0\\.4:8080(?:\\s|$)')
|
||||
- >-
|
||||
keycloak_production_listeners.stdout is not
|
||||
search('(?:^|\\s)(?:0\\.0\\.0\\.0|\\*):8080(?:\\s|$)')
|
||||
- >-
|
||||
keycloak_production_listeners.stdout is not
|
||||
search('(?:^|\\s)(?:[^\\s]+):(?:5432|9000)(?:\\s|$)')
|
||||
|
||||
- name: Read the managed nftables table
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/sbin/nft
|
||||
- list
|
||||
- table
|
||||
- inet
|
||||
- ghostnet_filter
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_production_firewall
|
||||
|
||||
- name: Verify the production network boundary
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'policy drop' in keycloak_production_firewall.stdout"
|
||||
- "'10.0.0.10' in keycloak_production_firewall.stdout"
|
||||
- "'tcp dport 8080' in keycloak_production_firewall.stdout"
|
||||
- "'10.89.0.0/24' in keycloak_production_firewall.stdout"
|
||||
|
||||
- name: Verify public realm discovery through the active proxy
|
||||
become: false
|
||||
ansible.builtin.uri:
|
||||
url: >-
|
||||
https://auth.ghostnet.selfhost.eu/realms/Ghost.net/.well-known/openid-configuration
|
||||
method: GET
|
||||
return_content: true
|
||||
status_code: 200
|
||||
validate_certs: true
|
||||
delegate_to: localhost
|
||||
run_once: true
|
||||
register: keycloak_production_discovery
|
||||
|
||||
- name: Verify the canonical public issuer
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_production_discovery.json.issuer ==
|
||||
'https://auth.ghostnet.selfhost.eu/realms/Ghost.net'
|
||||
|
||||
- name: Verify public administration remains denied
|
||||
become: false
|
||||
ansible.builtin.uri:
|
||||
url: https://auth.ghostnet.selfhost.eu/admin/master/console/
|
||||
method: GET
|
||||
status_code: 403
|
||||
validate_certs: true
|
||||
delegate_to: localhost
|
||||
run_once: true
|
||||
|
||||
- name: Verify internal administration through split DNS
|
||||
become: false
|
||||
ansible.builtin.uri:
|
||||
url: https://auth-admin.ghostnet.selfhost.eu/admin/master/console/
|
||||
method: GET
|
||||
status_code: 200
|
||||
validate_certs: true
|
||||
delegate_to: localhost
|
||||
run_once: true
|
||||
|
||||
- name: Verify the cross-origin login-status initialization
|
||||
become: false
|
||||
ansible.builtin.uri:
|
||||
url: >-
|
||||
https://auth.ghostnet.selfhost.eu/realms/master/protocol/openid-connect/login-status-iframe.html/init?client_id=security-admin-console&origin=https%3A%2F%2Fauth-admin.ghostnet.selfhost.eu
|
||||
method: GET
|
||||
status_code: 204
|
||||
validate_certs: true
|
||||
delegate_to: localhost
|
||||
run_once: true
|
||||
|
||||
- name: Read failed systemd units
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/systemctl
|
||||
- --failed
|
||||
- --no-legend
|
||||
- --plain
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_production_failed_units
|
||||
|
||||
- name: Require a healthy production cutover state
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_production_failed_units.stdout | trim == ''
|
||||
success_msg: >-
|
||||
CT 102 is the healthy production Keycloak target with the accepted
|
||||
restore, restricted listeners and proxy security boundaries intact.
|
||||
@@ -0,0 +1,18 @@
|
||||
# `keycloak_access`
|
||||
|
||||
Declaratively manages the three CT 102 access identities and the SSH trust
|
||||
boundary:
|
||||
|
||||
- `ghost-agent` (UID/GID 1000), locked and without sudo;
|
||||
- `ghost` (UID/GID 1001), personal password-protected sudo access;
|
||||
- `ansible-deploy` (UID/GID 1002), locked and intentionally root-equivalent
|
||||
through validated non-interactive sudo.
|
||||
|
||||
Each account receives a separate public key. Authorized-key restrictions bind
|
||||
the keys to the declared LAN and administration-VPN source networks. The role
|
||||
disables SSH password and root login, validates the generated sudoers and
|
||||
OpenSSH configuration before activation, and verifies the effective policy.
|
||||
|
||||
Private keys and the personal `ghost` sudo password are never managed by this
|
||||
role or stored in the repository. Inputs are documented in
|
||||
`meta/argument_specs.yml`.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
keycloak_access_source_networks: 10.0.0.0/24,10.8.0.0/24
|
||||
keycloak_access_public_key_root: "{{ playbook_dir }}/../files/ssh"
|
||||
|
||||
keycloak_access_agent_user: ghost-agent
|
||||
keycloak_access_agent_uid: 1000
|
||||
keycloak_access_agent_public_key_file: >-
|
||||
{{ keycloak_access_public_key_root }}/keycloak-ghost-agent.pub
|
||||
|
||||
keycloak_access_admin_user: ghost
|
||||
keycloak_access_admin_uid: 1001
|
||||
keycloak_access_admin_public_key_file: >-
|
||||
{{ keycloak_access_public_key_root }}/keycloak-ghost.pub
|
||||
|
||||
keycloak_access_deploy_user: ansible-deploy
|
||||
keycloak_access_deploy_uid: 1002
|
||||
keycloak_access_deploy_public_key_file: >-
|
||||
{{ keycloak_access_public_key_root }}/keycloak-ansible-deploy.pub
|
||||
|
||||
keycloak_access_ssh_dropin: /etc/ssh/sshd_config.d/00-ghostnet-bootstrap.conf
|
||||
keycloak_access_sudoers_file: /etc/sudoers.d/90-ghostnet-ansible-deploy
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
- name: Restart the classic SSH service
|
||||
ansible.builtin.systemd_service:
|
||||
name: ssh.service
|
||||
state: restarted
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Manage the Keycloak LXC access and SSH boundary
|
||||
options:
|
||||
keycloak_access_source_networks:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_access_public_key_root:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_access_agent_user:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_access_agent_uid:
|
||||
type: int
|
||||
required: true
|
||||
keycloak_access_agent_public_key_file:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_access_admin_user:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_access_admin_uid:
|
||||
type: int
|
||||
required: true
|
||||
keycloak_access_admin_public_key_file:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_access_deploy_user:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_access_deploy_uid:
|
||||
type: int
|
||||
required: true
|
||||
keycloak_access_deploy_public_key_file:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_access_ssh_dropin:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_access_sudoers_file:
|
||||
type: path
|
||||
required: true
|
||||
@@ -0,0 +1,233 @@
|
||||
---
|
||||
- name: Create fixed primary groups for Keycloak access accounts
|
||||
ansible.builtin.group:
|
||||
name: "{{ item.name }}"
|
||||
gid: "{{ item.uid }}"
|
||||
state: present
|
||||
loop:
|
||||
- name: "{{ keycloak_access_agent_user }}"
|
||||
uid: "{{ keycloak_access_agent_uid }}"
|
||||
- name: "{{ keycloak_access_admin_user }}"
|
||||
uid: "{{ keycloak_access_admin_uid }}"
|
||||
- name: "{{ keycloak_access_deploy_user }}"
|
||||
uid: "{{ keycloak_access_deploy_uid }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
|
||||
- name: Create fixed Keycloak access accounts
|
||||
ansible.builtin.user:
|
||||
name: "{{ item.name }}"
|
||||
uid: "{{ item.uid }}"
|
||||
group: "{{ item.name }}"
|
||||
home: "/home/{{ item.name }}"
|
||||
shell: /bin/bash
|
||||
create_home: true
|
||||
state: present
|
||||
loop:
|
||||
- name: "{{ keycloak_access_agent_user }}"
|
||||
uid: "{{ keycloak_access_agent_uid }}"
|
||||
- name: "{{ keycloak_access_admin_user }}"
|
||||
uid: "{{ keycloak_access_admin_uid }}"
|
||||
- name: "{{ keycloak_access_deploy_user }}"
|
||||
uid: "{{ keycloak_access_deploy_uid }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
|
||||
- name: Keep the read-only agent outside supplementary groups
|
||||
ansible.builtin.user:
|
||||
name: "{{ keycloak_access_agent_user }}"
|
||||
groups: ""
|
||||
append: false
|
||||
password_lock: true
|
||||
|
||||
- name: Keep the deployment account outside supplementary groups
|
||||
ansible.builtin.user:
|
||||
name: "{{ keycloak_access_deploy_user }}"
|
||||
groups: ""
|
||||
append: false
|
||||
password_lock: true
|
||||
|
||||
- name: Grant password-gated sudo membership to the administrator
|
||||
ansible.builtin.user:
|
||||
name: "{{ keycloak_access_admin_user }}"
|
||||
groups:
|
||||
- sudo
|
||||
append: true
|
||||
|
||||
- name: Create protected SSH directories
|
||||
ansible.builtin.file:
|
||||
path: "/home/{{ item }}/.ssh"
|
||||
state: directory
|
||||
owner: "{{ item }}"
|
||||
group: "{{ item }}"
|
||||
mode: "0700"
|
||||
loop:
|
||||
- "{{ keycloak_access_agent_user }}"
|
||||
- "{{ keycloak_access_admin_user }}"
|
||||
- "{{ keycloak_access_deploy_user }}"
|
||||
|
||||
- name: Install the restricted read-only agent key
|
||||
ansible.builtin.copy:
|
||||
content: |
|
||||
restrict,from="{{ keycloak_access_source_networks }}" {{ lookup('ansible.builtin.file', keycloak_access_agent_public_key_file) }}
|
||||
dest: "/home/{{ keycloak_access_agent_user }}/.ssh/authorized_keys"
|
||||
owner: "{{ keycloak_access_agent_user }}"
|
||||
group: "{{ keycloak_access_agent_user }}"
|
||||
mode: "0600"
|
||||
|
||||
- name: Install the restricted personal administrator key
|
||||
ansible.builtin.copy:
|
||||
content: |
|
||||
from="{{ keycloak_access_source_networks }}",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-user-rc {{ lookup('ansible.builtin.file', keycloak_access_admin_public_key_file) }}
|
||||
dest: "/home/{{ keycloak_access_admin_user }}/.ssh/authorized_keys"
|
||||
owner: "{{ keycloak_access_admin_user }}"
|
||||
group: "{{ keycloak_access_admin_user }}"
|
||||
mode: "0600"
|
||||
|
||||
- name: Install the restricted deployment key
|
||||
ansible.builtin.copy:
|
||||
content: |
|
||||
restrict,from="{{ keycloak_access_source_networks }}" {{ lookup('ansible.builtin.file', keycloak_access_deploy_public_key_file) }}
|
||||
dest: "/home/{{ keycloak_access_deploy_user }}/.ssh/authorized_keys"
|
||||
owner: "{{ keycloak_access_deploy_user }}"
|
||||
group: "{{ keycloak_access_deploy_user }}"
|
||||
mode: "0600"
|
||||
|
||||
- name: Install validated deployment sudo policy
|
||||
ansible.builtin.copy:
|
||||
content: |
|
||||
{{ keycloak_access_deploy_user }} ALL=(root) NOPASSWD: ALL
|
||||
dest: "{{ keycloak_access_sudoers_file }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0440"
|
||||
validate: /usr/sbin/visudo -cf %s
|
||||
|
||||
- name: Ensure root has no SSH authorization material
|
||||
ansible.builtin.file:
|
||||
path: /root/.ssh
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Keep the root authorized keys file empty
|
||||
ansible.builtin.copy:
|
||||
content: ""
|
||||
dest: /root/.ssh/authorized_keys
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
|
||||
- name: Install the hardened SSH policy
|
||||
ansible.builtin.template:
|
||||
src: sshd.conf.j2
|
||||
dest: "{{ keycloak_access_ssh_dropin }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
validate: /usr/sbin/sshd -t -f %s
|
||||
notify: Restart the classic SSH service
|
||||
|
||||
- name: Disable SSH socket activation
|
||||
ansible.builtin.systemd_service:
|
||||
name: ssh.socket
|
||||
enabled: false
|
||||
state: stopped
|
||||
|
||||
- name: Enable the classic SSH service
|
||||
ansible.builtin.systemd_service:
|
||||
name: ssh.service
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Read account password states
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/passwd
|
||||
- -S
|
||||
- "{{ item }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
loop:
|
||||
- "{{ keycloak_access_agent_user }}"
|
||||
- "{{ keycloak_access_admin_user }}"
|
||||
- "{{ keycloak_access_deploy_user }}"
|
||||
register: keycloak_access_password_states
|
||||
|
||||
- name: Verify account password states
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_access_password_states.results[0].stdout.split()[1] == "L"
|
||||
- keycloak_access_password_states.results[1].stdout.split()[1] == "P"
|
||||
- keycloak_access_password_states.results[2].stdout.split()[1] == "L"
|
||||
fail_msg: >-
|
||||
ghost-agent and ansible-deploy must remain locked, while ghost must keep
|
||||
the independently assigned password used only for sudo.
|
||||
|
||||
- name: Read effective supplementary groups
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/id
|
||||
- -Gn
|
||||
- "{{ item }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
loop:
|
||||
- "{{ keycloak_access_agent_user }}"
|
||||
- "{{ keycloak_access_deploy_user }}"
|
||||
register: keycloak_access_restricted_groups
|
||||
|
||||
- name: Verify restricted accounts have no supplementary groups
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_access_restricted_groups.results[0].stdout ==
|
||||
keycloak_access_agent_user
|
||||
- >-
|
||||
keycloak_access_restricted_groups.results[1].stdout ==
|
||||
keycloak_access_deploy_user
|
||||
|
||||
- name: Verify non-interactive deployment sudo
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/id
|
||||
- -u
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_access_deploy_root_identity
|
||||
|
||||
- name: Require effective root through the deployment account
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_access_deploy_root_identity.stdout == "0"
|
||||
|
||||
- name: Flush SSH policy handlers before final validation
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Validate the effective SSH policy
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/sbin/sshd
|
||||
- -T
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_access_effective_sshd
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify critical effective SSH settings
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'permitrootlogin no' in keycloak_access_effective_sshd.stdout_lines"
|
||||
- "'passwordauthentication no' in keycloak_access_effective_sshd.stdout_lines"
|
||||
- "'kbdinteractiveauthentication no' in keycloak_access_effective_sshd.stdout_lines"
|
||||
- "'pubkeyauthentication yes' in keycloak_access_effective_sshd.stdout_lines"
|
||||
- "'allowtcpforwarding no' in keycloak_access_effective_sshd.stdout_lines"
|
||||
- "'allowstreamlocalforwarding no' in keycloak_access_effective_sshd.stdout_lines"
|
||||
- "'permittunnel no' in keycloak_access_effective_sshd.stdout_lines"
|
||||
- "'maxauthtries 3' in keycloak_access_effective_sshd.stdout_lines"
|
||||
- "'logingracetime 30' in keycloak_access_effective_sshd.stdout_lines"
|
||||
- "'loglevel VERBOSE' in keycloak_access_effective_sshd.stdout_lines"
|
||||
fail_msg: The effective SSH policy differs from the declared baseline.
|
||||
when: not ansible_check_mode
|
||||
@@ -0,0 +1,14 @@
|
||||
# Managed by Ansible role keycloak_access.
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
AllowUsers {{ keycloak_access_agent_user }} {{ keycloak_access_admin_user }} {{ keycloak_access_deploy_user }}
|
||||
X11Forwarding no
|
||||
AllowAgentForwarding no
|
||||
AllowTcpForwarding no
|
||||
AllowStreamLocalForwarding no
|
||||
PermitTunnel no
|
||||
MaxAuthTries 3
|
||||
LoginGraceTime 30
|
||||
LogLevel VERBOSE
|
||||
@@ -0,0 +1,13 @@
|
||||
# `keycloak_firewall`
|
||||
|
||||
Owns the `inet ghostnet_filter` nftables table for the Keycloak LXC. It keeps
|
||||
the host input and forwarding policies deny-by-default while allowing:
|
||||
|
||||
- SSH only from the declared management networks;
|
||||
- Keycloak TCP/8080 only from the Nginx Proxy Manager addresses;
|
||||
- Aardvark DNS traffic from the private Podman subnet; and
|
||||
- outbound traffic initiated by containers on that subnet.
|
||||
|
||||
Before replacing the ruleset the role verifies that the active Ansible SSH
|
||||
peer remains covered by the management CIDRs. Netavark-owned nftables tables
|
||||
are outside this role. Inputs are documented in `meta/argument_specs.yml`.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
keycloak_firewall_management_ipv4:
|
||||
- 10.0.0.0/24
|
||||
- 10.8.0.0/24
|
||||
keycloak_firewall_reverse_proxy_ipv4:
|
||||
- 10.0.0.10
|
||||
keycloak_firewall_podman_subnet: 10.89.0.0/24
|
||||
keycloak_firewall_podman_gateway: 10.89.0.1
|
||||
keycloak_firewall_keycloak_ipv4: 10.89.0.20
|
||||
keycloak_firewall_ssh_port: 22
|
||||
keycloak_firewall_keycloak_port: 8080
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
- name: Reload systemd for the Keycloak firewall
|
||||
ansible.builtin.systemd_service:
|
||||
daemon_reload: true
|
||||
|
||||
- name: Restart the Keycloak firewall
|
||||
ansible.builtin.systemd_service:
|
||||
name: nftables.service
|
||||
enabled: true
|
||||
state: restarted
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Manage the Keycloak LXC nftables boundary
|
||||
options:
|
||||
keycloak_firewall_management_ipv4:
|
||||
type: list
|
||||
elements: str
|
||||
required: true
|
||||
keycloak_firewall_reverse_proxy_ipv4:
|
||||
type: list
|
||||
elements: str
|
||||
required: true
|
||||
keycloak_firewall_podman_subnet:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_firewall_podman_gateway:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_firewall_keycloak_ipv4:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_firewall_ssh_port:
|
||||
type: int
|
||||
required: true
|
||||
keycloak_firewall_keycloak_port:
|
||||
type: int
|
||||
required: true
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
- name: Require a non-empty management and reverse-proxy allowlist
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_firewall_management_ipv4 | length > 0
|
||||
- keycloak_firewall_reverse_proxy_ipv4 | length > 0
|
||||
fail_msg: The firewall allowlists must be explicit and non-empty.
|
||||
|
||||
- name: Read the active Ansible SSH transport endpoints
|
||||
become: false
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/printenv
|
||||
- SSH_CONNECTION
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_firewall_ssh_connection
|
||||
|
||||
- name: Require the active Ansible SSH source in the management allowlist
|
||||
ansible.builtin.command:
|
||||
argv: >-
|
||||
{{
|
||||
[
|
||||
'/usr/bin/python3',
|
||||
'-c',
|
||||
'import ipaddress,sys; address=ipaddress.ip_address(sys.argv[1]); raise SystemExit(0 if any(address in ipaddress.ip_network(network) for network in sys.argv[2:]) else 1)',
|
||||
keycloak_firewall_ssh_connection.stdout.split()[0]
|
||||
] + keycloak_firewall_management_ipv4
|
||||
}}
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
vars:
|
||||
ansible_command_timeout: 5
|
||||
|
||||
- name: Create the nftables service override directory
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/system/nftables.service.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Install the scoped nftables stop override
|
||||
ansible.builtin.template:
|
||||
src: nftables.service-override.conf.j2
|
||||
dest: /etc/systemd/system/nftables.service.d/10-ghostnet-preserve-netavark.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify:
|
||||
- Reload systemd for the Keycloak firewall
|
||||
- Restart the Keycloak firewall
|
||||
|
||||
- name: Install and validate the Keycloak nftables policy
|
||||
ansible.builtin.template:
|
||||
src: nftables.conf.j2
|
||||
dest: /etc/nftables.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
validate: /usr/sbin/nft --check --file %s
|
||||
notify: Restart the Keycloak firewall
|
||||
|
||||
- name: Flush firewall handlers before verification
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Read the active Ghostnet nftables table
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/sbin/nft
|
||||
- list
|
||||
- table
|
||||
- inet
|
||||
- ghostnet_filter
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_firewall_active_table
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify the active firewall policy and service
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_firewall_active_table.stdout is search('policy drop')
|
||||
- keycloak_firewall_active_table.stdout is search('SSH management')
|
||||
- keycloak_firewall_active_table.stdout is search('NPM to Keycloak host port')
|
||||
- keycloak_firewall_active_table.stdout is search('Podman network egress')
|
||||
when: not ansible_check_mode
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/sbin/nft -f
|
||||
|
||||
# Managed by Ansible role keycloak_firewall.
|
||||
# Replace only the Ghostnet-owned table. Netavark manages separate tables.
|
||||
destroy table inet ghostnet_filter
|
||||
|
||||
table inet ghostnet_filter {
|
||||
set management_ipv4 {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
elements = { {{ keycloak_firewall_management_ipv4 | join(', ') }} }
|
||||
}
|
||||
|
||||
set reverse_proxy_ipv4 {
|
||||
type ipv4_addr
|
||||
elements = { {{ keycloak_firewall_reverse_proxy_ipv4 | join(', ') }} }
|
||||
}
|
||||
|
||||
chain input {
|
||||
type filter hook input priority 10; policy drop;
|
||||
|
||||
iifname "lo" counter accept comment "local loopback"
|
||||
ct state established,related counter accept
|
||||
ct state invalid counter drop
|
||||
|
||||
meta l4proto icmp counter accept comment "IPv4 diagnostics"
|
||||
meta l4proto ipv6-icmp counter accept comment "IPv6 neighbor discovery"
|
||||
|
||||
ip saddr @management_ipv4 tcp dport {{ keycloak_firewall_ssh_port }} ct state new counter accept comment "SSH management"
|
||||
ip saddr @reverse_proxy_ipv4 tcp dport {{ keycloak_firewall_keycloak_port }} ct state new counter accept comment "NPM to Keycloak host port"
|
||||
|
||||
ip saddr {{ keycloak_firewall_podman_subnet }} ip daddr {{ keycloak_firewall_podman_gateway }} udp dport 53 counter accept comment "Aardvark DNS UDP"
|
||||
ip saddr {{ keycloak_firewall_podman_subnet }} ip daddr {{ keycloak_firewall_podman_gateway }} tcp dport 53 counter accept comment "Aardvark DNS TCP"
|
||||
|
||||
counter drop comment "default input deny"
|
||||
}
|
||||
|
||||
chain forward {
|
||||
type filter hook forward priority 10; policy drop;
|
||||
|
||||
ct state established,related counter accept
|
||||
ct state invalid counter drop
|
||||
|
||||
ip saddr {{ keycloak_firewall_podman_subnet }} counter accept comment "Podman network egress"
|
||||
ip saddr @reverse_proxy_ipv4 ip daddr {{ keycloak_firewall_keycloak_ipv4 }} tcp dport {{ keycloak_firewall_keycloak_port }} ct state new counter accept comment "NPM to Keycloak container"
|
||||
|
||||
counter drop comment "default forward deny"
|
||||
}
|
||||
|
||||
chain output {
|
||||
type filter hook output priority 10; policy accept;
|
||||
}
|
||||
}
|
||||
|
||||
{{ '' }}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
[Service]
|
||||
# Preserve Netavark's dynamic tables when nftables.service is stopped.
|
||||
ExecStop=
|
||||
ExecStop=/usr/sbin/nft destroy table inet ghostnet_filter
|
||||
{{ '\n' }}
|
||||
@@ -0,0 +1,21 @@
|
||||
# `keycloak_lxc_baseline`
|
||||
|
||||
Validates and configures the Debian LXC foundation used by the Keycloak
|
||||
stack. The role refuses an unexpected hostname, operating system, address or
|
||||
virtualization type before it installs baseline packages, sets the timezone,
|
||||
creates protected service directories and installs the reviewed forwarding
|
||||
sysctl configuration.
|
||||
|
||||
The role deliberately does not create access credentials, Podman resources or
|
||||
application services. Those boundaries belong to the subsequent dedicated
|
||||
roles.
|
||||
|
||||
## Important inputs
|
||||
|
||||
- `keycloak_lxc_baseline_expected_*`: immutable guest identity safeguards.
|
||||
- `keycloak_lxc_baseline_packages`: small package baseline.
|
||||
- `keycloak_lxc_baseline_directories`: ownership and mode declarations.
|
||||
- `keycloak_lxc_baseline_artifact_source_root`: reviewed host artifacts.
|
||||
|
||||
All supported inputs are documented and type-checked in
|
||||
`meta/argument_specs.yml`.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
keycloak_lxc_baseline_expected_hostname: keycloak
|
||||
keycloak_lxc_baseline_expected_distribution: Debian
|
||||
keycloak_lxc_baseline_expected_distribution_major_version: "13"
|
||||
keycloak_lxc_baseline_expected_ipv4: 10.0.0.4
|
||||
keycloak_lxc_baseline_expected_virtualization_type: lxc
|
||||
keycloak_lxc_baseline_timezone: Europe/Berlin
|
||||
|
||||
keycloak_lxc_baseline_packages:
|
||||
- ca-certificates
|
||||
- curl
|
||||
- jq
|
||||
- nftables
|
||||
- openssl
|
||||
|
||||
keycloak_lxc_baseline_artifact_source_root: >-
|
||||
{{ playbook_dir }}/../../../ct-104-keycloak
|
||||
|
||||
keycloak_lxc_baseline_directories:
|
||||
- path: /etc/ghostnet
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
- path: /etc/ghostnet/keycloak
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
- path: /etc/ghostnet/keycloak/secrets
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
- path: /etc/ghostnet/keycloak/postgres-init
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
- path: /var/lib/ghostnet
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
- path: /var/lib/ghostnet/keycloak
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
- path: /var/lib/ghostnet/keycloak/build
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
- path: /var/lib/ghostnet/keycloak/runtime
|
||||
owner: "1000"
|
||||
group: "0"
|
||||
mode: "0700"
|
||||
- path: /var/lib/ghostnet/keycloak/import
|
||||
owner: "1000"
|
||||
group: "0"
|
||||
mode: "0700"
|
||||
- path: /var/lib/ghostnet/keycloak/export
|
||||
owner: "1000"
|
||||
group: "0"
|
||||
mode: "0700"
|
||||
- path: /var/backups/ghostnet
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
- path: /var/backups/ghostnet/keycloak
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
- name: Load Keycloak forwarding sysctls
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/sbin/sysctl
|
||||
- -p
|
||||
- /etc/sysctl.d/90-ghostnet-container-forwarding.conf
|
||||
changed_when: true
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Configure the shared baseline for the Keycloak LXC
|
||||
options:
|
||||
keycloak_lxc_baseline_expected_hostname:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_lxc_baseline_expected_distribution:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_lxc_baseline_expected_distribution_major_version:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_lxc_baseline_expected_ipv4:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_lxc_baseline_expected_virtualization_type:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_lxc_baseline_timezone:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_lxc_baseline_packages:
|
||||
type: list
|
||||
elements: str
|
||||
required: true
|
||||
keycloak_lxc_baseline_artifact_source_root:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_lxc_baseline_directories:
|
||||
type: list
|
||||
elements: dict
|
||||
required: true
|
||||
options:
|
||||
path:
|
||||
type: path
|
||||
required: true
|
||||
owner:
|
||||
type: str
|
||||
required: true
|
||||
group:
|
||||
type: str
|
||||
required: true
|
||||
mode:
|
||||
type: str
|
||||
required: true
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
- name: Verify the Keycloak LXC identity before changing baseline state
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_facts["hostname"] == keycloak_lxc_baseline_expected_hostname
|
||||
- >-
|
||||
ansible_facts["distribution"] ==
|
||||
keycloak_lxc_baseline_expected_distribution
|
||||
- >-
|
||||
ansible_facts["distribution_major_version"] ==
|
||||
keycloak_lxc_baseline_expected_distribution_major_version
|
||||
- >-
|
||||
ansible_facts["default_ipv4"]["address"] ==
|
||||
keycloak_lxc_baseline_expected_ipv4
|
||||
- >-
|
||||
ansible_facts["virtualization_type"] ==
|
||||
keycloak_lxc_baseline_expected_virtualization_type
|
||||
- ansible_facts["virtualization_role"] == "guest"
|
||||
- ansible_facts["service_mgr"] == "systemd"
|
||||
fail_msg: >-
|
||||
The connected system is not the declared Keycloak LXC. No baseline
|
||||
changes may be applied.
|
||||
|
||||
- name: Install baseline administration packages
|
||||
ansible.builtin.apt:
|
||||
name: "{{ keycloak_lxc_baseline_packages }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
install_recommends: false
|
||||
|
||||
- name: Set the local timezone database link
|
||||
ansible.builtin.file:
|
||||
src: "/usr/share/zoneinfo/{{ keycloak_lxc_baseline_timezone }}"
|
||||
dest: /etc/localtime
|
||||
state: link
|
||||
owner: root
|
||||
group: root
|
||||
force: true
|
||||
|
||||
- name: Record the configured timezone
|
||||
ansible.builtin.copy:
|
||||
content: "{{ keycloak_lxc_baseline_timezone }}\n"
|
||||
dest: /etc/timezone
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
|
||||
- name: Create protected Keycloak configuration and data paths
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.path }}"
|
||||
state: directory
|
||||
owner: "{{ item.owner }}"
|
||||
group: "{{ item.group }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop: "{{ keycloak_lxc_baseline_directories }}"
|
||||
loop_control:
|
||||
label: "{{ item.path }}"
|
||||
|
||||
- name: Install the scoped container-forwarding sysctl policy
|
||||
ansible.builtin.copy:
|
||||
src: >-
|
||||
{{ keycloak_lxc_baseline_artifact_source_root }}/90-ghostnet-container-forwarding.conf
|
||||
dest: /etc/sysctl.d/90-ghostnet-container-forwarding.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Load Keycloak forwarding sysctls
|
||||
@@ -0,0 +1,17 @@
|
||||
# `keycloak_postgres`
|
||||
|
||||
Provisions the rootful PostgreSQL dependency for Keycloak from a digest-pinned
|
||||
amd64 image. It installs the reviewed Podman network, volume, container
|
||||
Quadlets and restricted database initialization script, then verifies the
|
||||
generated boot link, container health and authenticated non-superuser access.
|
||||
|
||||
On first commissioning the role generates two independent 256-bit passwords
|
||||
on the target. Their root-owned source files must contain exactly 64
|
||||
hexadecimal bytes without a trailing newline and use mode `0600`. Secret
|
||||
material is protected with `no_log`, remains outside Git and is imported into
|
||||
the Podman secret store. An existing Podman secret without its source file is
|
||||
treated as an unsafe orphan and stops the run.
|
||||
|
||||
The database is not published on a host port. Destructive database reset,
|
||||
credential rotation, dump and restore operations are intentionally outside
|
||||
this role. Inputs are documented in `meta/argument_specs.yml`.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
keycloak_postgres_artifact_source_root: >-
|
||||
{{ playbook_dir }}/../../../ct-104-keycloak
|
||||
keycloak_postgres_quadlet_directory: /etc/containers/systemd
|
||||
keycloak_postgres_config_directory: /etc/ghostnet/keycloak
|
||||
keycloak_postgres_init_directory: /etc/ghostnet/keycloak/postgres-init
|
||||
keycloak_postgres_secret_directory: /etc/ghostnet/keycloak/secrets
|
||||
keycloak_postgres_admin_secret_file: >-
|
||||
{{ keycloak_postgres_secret_directory }}/postgres-admin-password
|
||||
keycloak_postgres_app_secret_file: >-
|
||||
{{ keycloak_postgres_secret_directory }}/postgres-password
|
||||
keycloak_postgres_admin_secret_name: keycloak-db-admin-password
|
||||
keycloak_postgres_app_secret_name: keycloak-db-password
|
||||
keycloak_postgres_image: >-
|
||||
docker.io/library/postgres@sha256:ec9f656f17c8b4dfebf49e58cf5c46d493d4846d4ea6d8b85d0637f68ee33bfc
|
||||
keycloak_postgres_expected_architecture: amd64
|
||||
keycloak_postgres_container_name: ghostnet-keycloak-postgres
|
||||
keycloak_postgres_dependency_units:
|
||||
- ghostnet-keycloak-network.service
|
||||
- ghostnet-keycloak-postgres-volume.service
|
||||
keycloak_postgres_service_unit: ghostnet-keycloak-postgres.service
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
- name: Reload systemd for the Keycloak PostgreSQL Quadlets
|
||||
ansible.builtin.systemd_service:
|
||||
daemon_reload: true
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Provision the pinned Keycloak PostgreSQL service
|
||||
options:
|
||||
keycloak_postgres_artifact_source_root:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_postgres_quadlet_directory:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_postgres_config_directory:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_postgres_init_directory:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_postgres_secret_directory:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_postgres_admin_secret_file:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_postgres_app_secret_file:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_postgres_admin_secret_name:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_postgres_app_secret_name:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_postgres_image:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_postgres_expected_architecture:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_postgres_container_name:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_postgres_dependency_units:
|
||||
type: list
|
||||
elements: str
|
||||
required: true
|
||||
keycloak_postgres_service_unit:
|
||||
type: str
|
||||
required: true
|
||||
@@ -0,0 +1,289 @@
|
||||
---
|
||||
- name: Check the PostgreSQL password source files
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item.path }}"
|
||||
loop:
|
||||
- name: "{{ keycloak_postgres_admin_secret_name }}"
|
||||
path: "{{ keycloak_postgres_admin_secret_file }}"
|
||||
- name: "{{ keycloak_postgres_app_secret_name }}"
|
||||
path: "{{ keycloak_postgres_app_secret_file }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: keycloak_postgres_secret_files
|
||||
|
||||
- name: Inspect existing Podman secrets
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- secret
|
||||
- inspect
|
||||
- "{{ item }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
no_log: true
|
||||
loop:
|
||||
- "{{ keycloak_postgres_admin_secret_name }}"
|
||||
- "{{ keycloak_postgres_app_secret_name }}"
|
||||
register: keycloak_postgres_existing_secrets
|
||||
|
||||
- name: Refuse orphaned Podman secrets without their source files
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
not (
|
||||
keycloak_postgres_existing_secrets.results[item].rc == 0 and
|
||||
not keycloak_postgres_secret_files.results[item].stat.exists
|
||||
)
|
||||
fail_msg: >-
|
||||
A Podman secret exists without its root-owned source file. Restore the
|
||||
matching file or use the separately reviewed credential-rotation path.
|
||||
no_log: true
|
||||
loop:
|
||||
- 0
|
||||
- 1
|
||||
|
||||
- name: Generate missing PostgreSQL password material
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/openssl
|
||||
- rand
|
||||
- -hex
|
||||
- "32"
|
||||
changed_when: true
|
||||
no_log: true
|
||||
loop:
|
||||
- 0
|
||||
- 1
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- not keycloak_postgres_secret_files.results[item].stat.exists
|
||||
register: keycloak_postgres_generated_passwords
|
||||
|
||||
- name: Persist newly generated PostgreSQL password material
|
||||
ansible.builtin.copy:
|
||||
content: "{{ keycloak_postgres_generated_passwords.results[item].stdout }}"
|
||||
dest: "{{ keycloak_postgres_secret_files.results[item].item.path }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
no_log: true
|
||||
loop:
|
||||
- 0
|
||||
- 1
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- not keycloak_postgres_secret_files.results[item].stat.exists
|
||||
|
||||
- name: Read current PostgreSQL password source metadata
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item }}"
|
||||
loop:
|
||||
- "{{ keycloak_postgres_admin_secret_file }}"
|
||||
- "{{ keycloak_postgres_app_secret_file }}"
|
||||
register: keycloak_postgres_current_secret_files
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Require exact protected PostgreSQL password source files
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item.stat.exists
|
||||
- item.stat.isreg
|
||||
- item.stat.size == 64
|
||||
- item.stat.mode == "0600"
|
||||
- item.stat.pw_name == "root"
|
||||
- item.stat.gr_name == "root"
|
||||
fail_msg: >-
|
||||
PostgreSQL password sources must contain exactly 64 hexadecimal bytes
|
||||
without a trailing line ending and remain root-owned mode 0600.
|
||||
no_log: true
|
||||
loop: "{{ keycloak_postgres_current_secret_files.results }}"
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Validate PostgreSQL password source formats
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/grep
|
||||
- -Exq
|
||||
- "[[:xdigit:]]{64}"
|
||||
- "{{ item }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
no_log: true
|
||||
loop:
|
||||
- "{{ keycloak_postgres_admin_secret_file }}"
|
||||
- "{{ keycloak_postgres_app_secret_file }}"
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Create missing Podman secrets from protected source files
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- secret
|
||||
- create
|
||||
- "{{ keycloak_postgres_existing_secrets.results[item].item }}"
|
||||
- "{{ keycloak_postgres_secret_files.results[item].item.path }}"
|
||||
no_log: true
|
||||
loop:
|
||||
- 0
|
||||
- 1
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- keycloak_postgres_existing_secrets.results[item].rc != 0
|
||||
|
||||
- name: Check the pinned PostgreSQL image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- image
|
||||
- exists
|
||||
- "{{ keycloak_postgres_image }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
register: keycloak_postgres_image_exists
|
||||
|
||||
- name: Pull the pinned PostgreSQL image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- pull
|
||||
- --platform
|
||||
- linux/amd64
|
||||
- "{{ keycloak_postgres_image }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- keycloak_postgres_image_exists.rc != 0
|
||||
|
||||
- name: Inspect the pinned PostgreSQL image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- image
|
||||
- inspect
|
||||
- --format=json
|
||||
- "{{ keycloak_postgres_image }}"
|
||||
changed_when: false
|
||||
register: keycloak_postgres_image_inspect
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify the PostgreSQL image architecture
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
(keycloak_postgres_image_inspect.stdout | from_json)[0].Architecture ==
|
||||
keycloak_postgres_expected_architecture
|
||||
- (keycloak_postgres_image_inspect.stdout | from_json)[0].Os == "linux"
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Install the restricted Keycloak database initialization script
|
||||
ansible.builtin.copy:
|
||||
src: "{{ keycloak_postgres_artifact_source_root }}/10-create-keycloak-role.sh"
|
||||
dest: "{{ keycloak_postgres_init_directory }}/10-create-keycloak-role.sh"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0555"
|
||||
|
||||
- name: Install the PostgreSQL network and volume Quadlets
|
||||
ansible.builtin.copy:
|
||||
src: "{{ keycloak_postgres_artifact_source_root }}/{{ item }}"
|
||||
dest: "{{ keycloak_postgres_quadlet_directory }}/{{ item }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop:
|
||||
- ghostnet-keycloak.network
|
||||
- ghostnet-keycloak-postgres.volume
|
||||
notify: Reload systemd for the Keycloak PostgreSQL Quadlets
|
||||
|
||||
- name: Install the PostgreSQL container Quadlet
|
||||
ansible.builtin.copy:
|
||||
src: "{{ keycloak_postgres_artifact_source_root }}/ghostnet-keycloak-postgres.container"
|
||||
dest: "{{ keycloak_postgres_quadlet_directory }}/ghostnet-keycloak-postgres.container"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload systemd for the Keycloak PostgreSQL Quadlets
|
||||
|
||||
- name: Flush PostgreSQL Quadlet handlers before service activation
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Start the static PostgreSQL dependency units
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ item }}"
|
||||
state: started
|
||||
loop: "{{ keycloak_postgres_dependency_units }}"
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Start the PostgreSQL service and wait for its health notification
|
||||
ansible.builtin.systemd_service:
|
||||
name: "{{ keycloak_postgres_service_unit }}"
|
||||
state: started
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Check the generated PostgreSQL boot target link
|
||||
ansible.builtin.stat:
|
||||
path: >-
|
||||
/run/systemd/generator/multi-user.target.wants/{{ keycloak_postgres_service_unit }}
|
||||
follow: false
|
||||
register: keycloak_postgres_boot_link
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify PostgreSQL is selected for boot by the Quadlet generator
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_postgres_boot_link.stat.exists
|
||||
- keycloak_postgres_boot_link.stat.islnk
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Inspect PostgreSQL container health
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- inspect
|
||||
- --format=json
|
||||
- "{{ keycloak_postgres_container_name }}"
|
||||
changed_when: false
|
||||
register: keycloak_postgres_container_inspect
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify the PostgreSQL container state
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
(keycloak_postgres_container_inspect.stdout | from_json)[0].State.Status ==
|
||||
"running"
|
||||
- >-
|
||||
(keycloak_postgres_container_inspect.stdout | from_json)[0].State.Health.Status ==
|
||||
"healthy"
|
||||
- >-
|
||||
(keycloak_postgres_container_inspect.stdout | from_json)[0].Config.User ==
|
||||
"999:999"
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify authenticated restricted Keycloak database access
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- exec
|
||||
- "{{ keycloak_postgres_container_name }}"
|
||||
- /bin/sh
|
||||
- -eu
|
||||
- -c
|
||||
- >-
|
||||
PGPASSWORD="$(cat /run/secrets/keycloak-db-password)";
|
||||
export PGPASSWORD;
|
||||
exec psql -h 127.0.0.1 -U keycloak -d keycloak -At
|
||||
-c "select current_database() || '|' || current_user || '|' || pg_is_in_recovery();"
|
||||
changed_when: false
|
||||
register: keycloak_postgres_database_identity
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Require the expected writable primary database identity
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_postgres_database_identity.stdout ==
|
||||
"keycloak|keycloak|false"
|
||||
when: not ansible_check_mode
|
||||
@@ -0,0 +1,17 @@
|
||||
# `keycloak_service`
|
||||
|
||||
Builds and declares the Keycloak 26.2.4 application service. The source image
|
||||
is digest-pinned; the reviewed Containerfile checksum is verified on the
|
||||
control node. The optimized runtime image is built without network access and
|
||||
with a deterministic timestamp, inspected for its architecture and source
|
||||
label, and written into the Quadlet by immutable image ID.
|
||||
|
||||
Two explicit commissioning variables prevent premature activation:
|
||||
|
||||
- `keycloak_service_start_allowed` controls the root-owned start-gate marker;
|
||||
- `keycloak_service_production_enabled` controls restart policy and the
|
||||
generated `multi-user.target` boot link.
|
||||
|
||||
Production enablement is rejected unless the start gate is also open. Before
|
||||
the database restore both values stay `false`, so Keycloak is stopped and is
|
||||
not selected for boot. Inputs are documented in `meta/argument_specs.yml`.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
keycloak_service_artifact_source_root: >-
|
||||
{{ playbook_dir }}/../../../ct-104-keycloak
|
||||
keycloak_service_quadlet_directory: /etc/containers/systemd
|
||||
keycloak_service_build_directory: /var/lib/ghostnet/keycloak/build
|
||||
keycloak_service_config_directory: /etc/ghostnet/keycloak
|
||||
keycloak_service_start_gate: /etc/ghostnet/keycloak/allow-keycloak-start
|
||||
keycloak_service_containerfile_sha256: >-
|
||||
685209b713bdbceb08a75ecce14f886ae27ebf117c6f693c3bb06611f05e76f3
|
||||
keycloak_service_source_image: >-
|
||||
quay.io/keycloak/keycloak@sha256:b79f7fd8a3fc9d56ebd274d4e0a5ba996253dedf7ad2cb23ccac83a690a10bc6
|
||||
keycloak_service_runtime_tag: localhost/ghostnet/keycloak:26.2.4-ansible1
|
||||
keycloak_service_expected_architecture: amd64
|
||||
keycloak_service_publish_ipv4: "{{ keycloak_expected_ipv4 }}"
|
||||
keycloak_service_container_name: ghostnet-keycloak
|
||||
keycloak_service_start_allowed: false
|
||||
keycloak_service_production_enabled: false
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
- name: Reload systemd for the Keycloak application Quadlet
|
||||
ansible.builtin.systemd_service:
|
||||
daemon_reload: true
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
argument_specs:
|
||||
main:
|
||||
short_description: Build and gate the pinned Keycloak application service
|
||||
options:
|
||||
keycloak_service_artifact_source_root:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_service_quadlet_directory:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_service_build_directory:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_service_config_directory:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_service_start_gate:
|
||||
type: path
|
||||
required: true
|
||||
keycloak_service_containerfile_sha256:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_service_source_image:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_service_runtime_tag:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_service_expected_architecture:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_service_publish_ipv4:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_service_container_name:
|
||||
type: str
|
||||
required: true
|
||||
keycloak_service_start_allowed:
|
||||
type: bool
|
||||
required: true
|
||||
keycloak_service_production_enabled:
|
||||
type: bool
|
||||
required: true
|
||||
@@ -0,0 +1,271 @@
|
||||
---
|
||||
- name: Require production enablement only with an open start gate
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
not keycloak_service_production_enabled or
|
||||
keycloak_service_start_allowed
|
||||
fail_msg: >-
|
||||
Production enablement requires an explicitly open Keycloak start gate.
|
||||
|
||||
- name: Check the reviewed Containerfile checksum on the control node
|
||||
become: false
|
||||
ansible.builtin.stat:
|
||||
path: "{{ keycloak_service_artifact_source_root }}/Containerfile.keycloak"
|
||||
checksum_algorithm: sha256
|
||||
delegate_to: localhost
|
||||
register: keycloak_service_source_containerfile
|
||||
|
||||
- name: Require the pinned reviewed Containerfile
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- keycloak_service_source_containerfile.stat.exists
|
||||
- >-
|
||||
keycloak_service_source_containerfile.stat.checksum ==
|
||||
keycloak_service_containerfile_sha256
|
||||
fail_msg: The Keycloak Containerfile differs from its reviewed checksum.
|
||||
|
||||
- name: Install the dedicated Keycloak build context
|
||||
ansible.builtin.copy:
|
||||
src: "{{ keycloak_service_artifact_source_root }}/Containerfile.keycloak"
|
||||
dest: "{{ keycloak_service_build_directory }}/Containerfile.keycloak"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
|
||||
- name: Install the Keycloak healthcheck helper
|
||||
ansible.builtin.copy:
|
||||
src: "{{ keycloak_service_artifact_source_root }}/ghostnet-keycloak-healthcheck"
|
||||
dest: "{{ keycloak_service_config_directory }}/ghostnet-keycloak-healthcheck"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0555"
|
||||
|
||||
- name: Install the container-health wait helper
|
||||
ansible.builtin.copy:
|
||||
src: "{{ keycloak_service_artifact_source_root }}/ghostnet-wait-container-healthy"
|
||||
dest: /usr/local/sbin/ghostnet-wait-container-healthy
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Check the pinned Keycloak source image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- image
|
||||
- exists
|
||||
- "{{ keycloak_service_source_image }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
register: keycloak_service_source_image_exists
|
||||
|
||||
- name: Pull the pinned Keycloak source image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- pull
|
||||
- --platform
|
||||
- linux/amd64
|
||||
- "{{ keycloak_service_source_image }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- keycloak_service_source_image_exists.rc != 0
|
||||
|
||||
- name: Check the local optimized Keycloak image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- image
|
||||
- exists
|
||||
- "{{ keycloak_service_runtime_tag }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
register: keycloak_service_runtime_image_exists
|
||||
|
||||
- name: Inspect the existing optimized Keycloak image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- image
|
||||
- inspect
|
||||
- --format=json
|
||||
- "{{ keycloak_service_runtime_tag }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_service_existing_runtime_inspect
|
||||
when: keycloak_service_runtime_image_exists.rc == 0
|
||||
|
||||
- name: Decide whether the optimized Keycloak image needs rebuilding
|
||||
ansible.builtin.set_fact:
|
||||
keycloak_service_rebuild_required: >-
|
||||
{{
|
||||
keycloak_service_runtime_image_exists.rc != 0 or
|
||||
(
|
||||
(
|
||||
(keycloak_service_existing_runtime_inspect.stdout | from_json)[0]
|
||||
).Config.Labels['eu.selfhost.ghostnet.containerfile-sha256'] |
|
||||
default('')
|
||||
) != keycloak_service_containerfile_sha256
|
||||
}}
|
||||
|
||||
- name: Build the optimized Keycloak image from pinned local inputs
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- build
|
||||
- --pull=never
|
||||
- --network=none
|
||||
- --timestamp=0
|
||||
- --label
|
||||
- >-
|
||||
eu.selfhost.ghostnet.containerfile-sha256={{ keycloak_service_containerfile_sha256 }}
|
||||
- --tag
|
||||
- "{{ keycloak_service_runtime_tag }}"
|
||||
- --file
|
||||
- "{{ keycloak_service_build_directory }}/Containerfile.keycloak"
|
||||
- "{{ keycloak_service_build_directory }}"
|
||||
changed_when: true
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- keycloak_service_rebuild_required
|
||||
|
||||
- name: Inspect the selected optimized Keycloak image
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- image
|
||||
- inspect
|
||||
- --format=json
|
||||
- "{{ keycloak_service_runtime_tag }}"
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: keycloak_service_runtime_inspect
|
||||
when:
|
||||
- not ansible_check_mode or keycloak_service_runtime_image_exists.rc == 0
|
||||
|
||||
- name: Select the immutable runtime image ID
|
||||
ansible.builtin.set_fact:
|
||||
keycloak_service_runtime_image_id: >-
|
||||
{{ (keycloak_service_runtime_inspect.stdout | from_json)[0].Id }}
|
||||
when:
|
||||
- not ansible_check_mode or keycloak_service_runtime_image_exists.rc == 0
|
||||
|
||||
- name: Use a non-runnable placeholder image ID only for an empty check-mode target
|
||||
ansible.builtin.set_fact:
|
||||
keycloak_service_runtime_image_id: >-
|
||||
sha256:0000000000000000000000000000000000000000000000000000000000000000
|
||||
when:
|
||||
- ansible_check_mode
|
||||
- keycloak_service_runtime_image_exists.rc != 0
|
||||
|
||||
- name: Verify the optimized Keycloak image
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
(keycloak_service_runtime_inspect.stdout | from_json)[0].Architecture ==
|
||||
keycloak_service_expected_architecture
|
||||
- (keycloak_service_runtime_inspect.stdout | from_json)[0].Os == "linux"
|
||||
- >-
|
||||
(
|
||||
(keycloak_service_runtime_inspect.stdout | from_json)[0]
|
||||
).Config.Labels['eu.selfhost.ghostnet.containerfile-sha256'] ==
|
||||
keycloak_service_containerfile_sha256
|
||||
when:
|
||||
- not ansible_check_mode or keycloak_service_runtime_image_exists.rc == 0
|
||||
|
||||
- name: Keep the Keycloak start gate closed during commissioning
|
||||
ansible.builtin.file:
|
||||
path: "{{ keycloak_service_start_gate }}"
|
||||
state: absent
|
||||
when: not keycloak_service_start_allowed
|
||||
|
||||
- name: Open the explicit Keycloak start gate
|
||||
ansible.builtin.file:
|
||||
path: "{{ keycloak_service_start_gate }}"
|
||||
state: touch
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
modification_time: preserve
|
||||
access_time: preserve
|
||||
when: keycloak_service_start_allowed
|
||||
|
||||
- name: Install the gated Keycloak application Quadlet
|
||||
ansible.builtin.template:
|
||||
src: ghostnet-keycloak.container.j2
|
||||
dest: "{{ keycloak_service_quadlet_directory }}/ghostnet-keycloak.container"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload systemd for the Keycloak application Quadlet
|
||||
|
||||
- name: Flush the application Quadlet handler before state management
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Keep Keycloak stopped while the start gate is closed
|
||||
ansible.builtin.systemd_service:
|
||||
name: ghostnet-keycloak.service
|
||||
state: stopped
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- not keycloak_service_start_allowed
|
||||
|
||||
- name: Start Keycloak only after explicit gate authorization
|
||||
ansible.builtin.systemd_service:
|
||||
name: ghostnet-keycloak.service
|
||||
state: started
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- keycloak_service_start_allowed
|
||||
|
||||
- name: Check the generated Keycloak boot target link
|
||||
ansible.builtin.stat:
|
||||
path: >-
|
||||
/run/systemd/generator/multi-user.target.wants/ghostnet-keycloak.service
|
||||
follow: false
|
||||
register: keycloak_service_boot_link
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify the declared Keycloak boot policy
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
keycloak_service_boot_link.stat.exists ==
|
||||
keycloak_service_production_enabled
|
||||
- >-
|
||||
not keycloak_service_boot_link.stat.exists or
|
||||
keycloak_service_boot_link.stat.islnk
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Inspect the authorized Keycloak application state
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- /usr/bin/podman
|
||||
- inspect
|
||||
- --format=json
|
||||
- "{{ keycloak_service_container_name }}"
|
||||
changed_when: false
|
||||
register: keycloak_service_container_inspect
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- keycloak_service_start_allowed
|
||||
|
||||
- name: Verify the authorized Keycloak application is healthy
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
(keycloak_service_container_inspect.stdout | from_json)[0].State.Status ==
|
||||
"running"
|
||||
- >-
|
||||
(keycloak_service_container_inspect.stdout | from_json)[0].State.Health.Status ==
|
||||
"healthy"
|
||||
- >-
|
||||
(keycloak_service_container_inspect.stdout | from_json)[0].Config.User ==
|
||||
"1000:0"
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- keycloak_service_start_allowed
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
[Unit]
|
||||
Description=Ghostnet Keycloak 26.2.4 application server
|
||||
After=nftables.service ghostnet-keycloak-postgres.service
|
||||
Requires=nftables.service ghostnet-keycloak-postgres.service
|
||||
# The marker is managed separately from this Quadlet during commissioning.
|
||||
ConditionPathExists={{ keycloak_service_start_gate }}
|
||||
|
||||
[Container]
|
||||
Image={{ keycloak_service_runtime_image_id }}
|
||||
Pull=never
|
||||
ContainerName={{ keycloak_service_container_name }}
|
||||
|
||||
Network=ghostnet-keycloak.network
|
||||
NetworkAlias=keycloak
|
||||
IP=10.89.0.20
|
||||
PublishPort={{ keycloak_service_publish_ipv4 }}:8080:8080/tcp
|
||||
|
||||
Volume=/var/lib/ghostnet/keycloak/runtime:/opt/keycloak/data:rw
|
||||
Volume={{ keycloak_service_config_directory }}/ghostnet-keycloak-healthcheck:/opt/keycloak/bin/ghostnet-keycloak-healthcheck:ro
|
||||
Secret=keycloak-db-password,type=env,target=KC_DB_PASSWORD
|
||||
|
||||
Environment=KC_DB=postgres
|
||||
Environment=KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak
|
||||
Environment=KC_DB_USERNAME=keycloak
|
||||
Environment=KC_HTTP_ENABLED=true
|
||||
Environment=KC_HTTP_PORT=8080
|
||||
Environment=KC_HTTP_MANAGEMENT_SCHEME=http
|
||||
Environment=KC_HTTP_MANAGEMENT_PORT=9000
|
||||
Environment=KC_HTTP_MANAGEMENT_HEALTH_ENABLED=true
|
||||
Environment=KC_HOSTNAME=https://auth.ghostnet.selfhost.eu
|
||||
Environment=KC_HOSTNAME_ADMIN=https://auth-admin.ghostnet.selfhost.eu
|
||||
Environment=KC_HOSTNAME_STRICT=true
|
||||
Environment=KC_PROXY_HEADERS=xforwarded
|
||||
|
||||
Exec=start --optimized
|
||||
User=1000
|
||||
Group=0
|
||||
ReadOnly=true
|
||||
NoNewPrivileges=true
|
||||
DropCapability=all
|
||||
Tmpfs=/tmp:rw,size=64m,mode=1777
|
||||
PidsLimit=512
|
||||
LogDriver=journald
|
||||
|
||||
HealthCmd=/opt/keycloak/bin/ghostnet-keycloak-healthcheck
|
||||
HealthInterval=10s
|
||||
HealthTimeout=5s
|
||||
HealthRetries=12
|
||||
HealthStartPeriod=120s
|
||||
Notify=conmon
|
||||
StopSignal=SIGTERM
|
||||
StopTimeout=90
|
||||
|
||||
Label=eu.selfhost.ghostnet.stack=keycloak
|
||||
Label=eu.selfhost.ghostnet.role=identity-provider
|
||||
|
||||
[Service]
|
||||
{% if keycloak_service_production_enabled %}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
{% else %}
|
||||
Restart=no
|
||||
{% endif %}
|
||||
ExecStartPost=/usr/local/sbin/ghostnet-wait-container-healthy {{ keycloak_service_container_name }} 240
|
||||
TimeoutStartSec=300s
|
||||
TimeoutStopSec=150s
|
||||
MemoryHigh=1536M
|
||||
MemoryMax=2G
|
||||
TasksMax=512
|
||||
LimitNOFILE=65536
|
||||
{% if keycloak_service_production_enabled %}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
{% endif %}
|
||||
@@ -0,0 +1,213 @@
|
||||
# Proxmox automation
|
||||
|
||||
## CT 102 Keycloak provisioning profile
|
||||
|
||||
The replacement Keycloak LXC uses CT 104 as its measured reference. CT 104
|
||||
currently exposes 2 vCPUs, 4 GiB RAM, 1 GiB swap and a 12 GiB root filesystem;
|
||||
the complete running stack uses about 626 MiB RAM and 2.6 GiB disk. CT 102 is
|
||||
therefore provisioned with the same CPU and memory allocation but a moderately
|
||||
larger root disk for parallel image versions, package updates and Ansible
|
||||
staging:
|
||||
|
||||
| Setting | CT 102 value |
|
||||
| --- | --- |
|
||||
| CT ID / hostname | `102` / `keycloak` |
|
||||
| OS | Debian 13, amd64 |
|
||||
| Container type | unprivileged LXC with nesting enabled |
|
||||
| CPU | 2 cores |
|
||||
| Memory | 4096 MiB |
|
||||
| Swap | 1024 MiB |
|
||||
| Root disk | 16 GiB, thin-provisioned LXC storage |
|
||||
| Network | `vmbr0`, `10.0.0.4/24`, gateway `10.0.0.1`, IPv6 disabled |
|
||||
| Boot / protection | start on boot, protection enabled after creation |
|
||||
| Time zone | `Europe/Berlin` |
|
||||
| Initial root SSH | not required; the hook installs and hardens OpenSSH |
|
||||
| Post-install hook | `/usr/local/sbin/ghostnet-keycloak-ansible-bootstrap-hook.sh` |
|
||||
|
||||
The Community Scripts Podman helper does not currently expose a swap variable,
|
||||
so the reviewed launcher sets `pct set 102 --swap 1024` after successful
|
||||
creation and then verifies the complete PVE configuration. The 2026-10-12 PVE
|
||||
preflight established `local` as the active template storage and `local-lvm` as
|
||||
the active thin-provisioned container storage, with sufficient free capacity.
|
||||
|
||||
The website-generated launcher is stored as `provision-ct102-keycloak.sh`, not
|
||||
as the ambiguous `install.sh`. It contains no password or private key. It pins
|
||||
the Community Scripts `ProxmoxVE` and `core` repositories to the revisions
|
||||
audited on 2026-10-12, verifies the downloaded Podman entry script, names every
|
||||
material CT setting explicitly, and rejects an occupied CT ID instead of
|
||||
silently allowing the helper to choose another one.
|
||||
|
||||
Before running it, install the two hooks documented below on the PVE host and
|
||||
verify that CT 102 no longer exists. Then execute the reviewed host copy as
|
||||
root:
|
||||
|
||||
```bash
|
||||
/usr/local/sbin/provision-ct102-keycloak.sh
|
||||
```
|
||||
|
||||
The launcher deliberately does not delete or replace an existing container.
|
||||
After Community Scripts returns, it configures 1024 MiB swap and treats the
|
||||
otherwise non-fatal post-install hook result as mandatory: the PVE profile,
|
||||
Debian release, Podman installation, SSH service, account UIDs and unattended
|
||||
`ansible-deploy` sudo path must all pass before the launcher reports success.
|
||||
The reviewed launcher copy has SHA-256
|
||||
`5105ff39bbbf6959a91fc2e5bda67cbf3bcf3eacdae4dcac5d70f5d0a636bd6f`.
|
||||
|
||||
## `ghostnet-lxc-baseline.sh`
|
||||
|
||||
The versioned source now lives at
|
||||
`../../../shared/scripts/ghostnet-lxc-baseline.sh`. This script is the optional Community Scripts
|
||||
post-install hook. Install the reviewed version on the Proxmox host as:
|
||||
|
||||
```text
|
||||
/usr/local/sbin/ghostnet-lxc-baseline.sh
|
||||
```
|
||||
|
||||
The host-side copy is owned by `root:root` with mode `0750`. Verify its
|
||||
SHA-256 against the reviewed repository copy before executing it.
|
||||
|
||||
The Community Scripts field must contain that absolute path. The hook runs as
|
||||
root on the Proxmox host after the guest has been provisioned and started.
|
||||
Community Scripts supplies `CTID`; for a controlled rerun the CT ID can also be
|
||||
passed as the first argument:
|
||||
|
||||
```bash
|
||||
/usr/local/sbin/ghostnet-lxc-baseline.sh 104
|
||||
```
|
||||
|
||||
The script is deliberately limited. It:
|
||||
|
||||
- requires an existing, running, unprivileged Debian 12 or newer LXC;
|
||||
- aborts before account creation if UID/GID 1000 or 1001 is assigned or owns
|
||||
files unexpectedly;
|
||||
- installs `ca-certificates`, `openssh-server` and `sudo` only when missing;
|
||||
it does not use the hook as a general package-upgrade mechanism;
|
||||
- creates or validates locked `ghost-agent` UID/GID 1000 and `ghost` UID/GID
|
||||
1001 accounts;
|
||||
- grants only `ghost` membership in `sudo`;
|
||||
- prepares protected, empty `authorized_keys` files without truncating an
|
||||
existing file;
|
||||
- disables SSH socket activation, enables and restarts the classic SSH service,
|
||||
validates its syntax and reports the guest host-key fingerprint.
|
||||
- removes only the known dangling `wtmpdb-rotate.timer` enable link left by
|
||||
the Debian 13 package transition, and only when its target is absent and no
|
||||
installed package owns that target.
|
||||
|
||||
It intentionally does **not** install public or private keys, set passwords,
|
||||
change root authentication, write an SSH hardening drop-in, configure a
|
||||
firewall, alter application users, or install application workloads. Those
|
||||
steps remain host-specific and require positive and negative access tests.
|
||||
|
||||
The script is idempotent for an already compliant guest. Any identity or
|
||||
numeric-owner mismatch stops the hook instead of guessing a replacement UID.
|
||||
Community Scripts retains the created LXC if the hook fails and writes hook
|
||||
output to `/var/log/community-scripts/post-install-<CTID>.log` on the Proxmox
|
||||
host.
|
||||
|
||||
The current reviewed copy has SHA-256
|
||||
`111f1b70e47e3a2f6a1bcce22cb0ec50aacd8fbcff37d87fbcf853a318381b10`.
|
||||
An earlier copy with SHA-256
|
||||
`a0ee32a8fc2854c29a83b17b4e0cdb5ad55cde36de92d562f6bd890dc93ae89e`
|
||||
was installed on the Ghostnet Proxmox host and rerun against CT 104 on
|
||||
2026-10-06. The convergence run performed no package transaction, preserved
|
||||
both authorized-key files and passed the subsequent access and SSH-denial
|
||||
tests.
|
||||
|
||||
## `ghostnet-keycloak-ansible-bootstrap-hook.sh`
|
||||
|
||||
This is the CT-102-specific Community Scripts post-install hook for the
|
||||
reproducible Keycloak rebuild. It calls the generic baseline above and then
|
||||
establishes only the transport and privilege boundary needed by Ansible:
|
||||
|
||||
- it refuses any CT ID other than 102, any helper application other than
|
||||
Podman, a hostname other than `keycloak`, or a guest without `10.0.0.4`;
|
||||
- it creates or validates `ghost-agent` 1000:1000, `ghost` 1001:1001 and
|
||||
`ansible-deploy` 1002:1002;
|
||||
- it installs the three role-specific public keys, limited to the documented
|
||||
management networks; the embedded keys must remain identical to
|
||||
`../ansible/files/ssh/keycloak-{ghost-agent,ghost,ansible-deploy}.pub`;
|
||||
- it keeps `ghost-agent` and `ansible-deploy` passwords locked, grants only
|
||||
`ansible-deploy` reviewed `NOPASSWD: ALL`, and leaves `ghost` on Debian's
|
||||
password-gated `sudo` policy;
|
||||
- it disables SSH root/password authentication, empties the helper's temporary
|
||||
root `authorized_keys`, and validates SSH and sudo before returning success.
|
||||
|
||||
The hook does not configure nftables, Podman, PostgreSQL or Keycloak. Those
|
||||
states belong to the Ansible roles and their idempotence tests.
|
||||
|
||||
The reviewed repository copy currently has SHA-256
|
||||
`0e0499bc44834fdcc7d31814a8b2b4a2bed527fc8d4051e66d6cd92ac32b86c3`.
|
||||
Verify the PVE-host copy against it before selecting the hook.
|
||||
|
||||
Install both reviewed scripts on the PVE host before launching the helper:
|
||||
|
||||
```bash
|
||||
install -o root -g root -m 0750 \
|
||||
ghostnet-lxc-baseline.sh \
|
||||
/usr/local/sbin/ghostnet-lxc-baseline.sh
|
||||
|
||||
install -o root -g root -m 0700 \
|
||||
ghostnet-keycloak-ansible-bootstrap-hook.sh \
|
||||
/usr/local/sbin/ghostnet-keycloak-ansible-bootstrap-hook.sh
|
||||
```
|
||||
|
||||
In the Podman helper's advanced settings, set the post-install hook to the
|
||||
absolute PVE-host path:
|
||||
|
||||
```text
|
||||
/usr/local/sbin/ghostnet-keycloak-ansible-bootstrap-hook.sh
|
||||
```
|
||||
|
||||
Do not put this CT-specific path in a global Community Scripts defaults file.
|
||||
The helper executes it as PVE root after starting the new LXC and writes its
|
||||
output to `/var/log/community-scripts/post-install-102.log`. A non-zero hook
|
||||
exit is reported by the helper but does not roll back the already-created
|
||||
container or make the helper remove it; inspect the log and the resulting
|
||||
guest before any rerun.
|
||||
|
||||
The script deliberately cannot provision the personal `ghost` password. Set
|
||||
it without exposing it in chat or shell history:
|
||||
|
||||
```bash
|
||||
pct exec 102 -- passwd ghost
|
||||
```
|
||||
|
||||
After the hook, independently verify the new SSH host-key fingerprint through
|
||||
the PVE console, replace the obsolete CT-102 known-hosts entry, test all three
|
||||
positive/negative access paths, and only then run the Ansible preflight.
|
||||
|
||||
## `prepare-keycloak-cutover-dump.sh`
|
||||
|
||||
This PVE-root operator script starts the final write freeze and therefore
|
||||
causes a deliberate Keycloak outage. It refuses to run unless CT 104, CT 102
|
||||
and CT 103 are running, both PostgreSQL services are healthy, CT 102 remains
|
||||
empty and gated, and both NPM Keycloak proxy hosts still target CT 104 at
|
||||
`10.0.0.16`.
|
||||
|
||||
After the explicit confirmation it stops only the CT 104 Keycloak application,
|
||||
leaves its PostgreSQL running, creates and catalogs a custom-format
|
||||
`pg_dump --no-owner --no-acl`, records the non-secret schema/realm summary,
|
||||
and transfers all root-only artifacts through PVE into CT 102. SHA-256 is
|
||||
checked at the source, on PVE and at the destination. If any step fails before
|
||||
the target transfer has been fully verified, the trap attempts to restart CT
|
||||
104 Keycloak. On success CT 104 deliberately remains stopped to prevent new
|
||||
writes during restore and cutover.
|
||||
|
||||
Install the reviewed script as root on PVE and invoke it only at the agreed
|
||||
outage window:
|
||||
|
||||
```bash
|
||||
install -o root -g root -m 0700 \
|
||||
prepare-keycloak-cutover-dump.sh \
|
||||
/usr/local/sbin/prepare-keycloak-cutover-dump.sh
|
||||
|
||||
GHOSTNET_CUTOVER_CONFIRMATION=STOP_CT104_AND_PREPARE_FINAL_KEYCLOAK_DUMP \
|
||||
/usr/local/sbin/prepare-keycloak-cutover-dump.sh
|
||||
```
|
||||
|
||||
The final output supplies the CT 102 dump path and digest required by
|
||||
`../ansible/playbooks/restore.yml`. Do not restart CT 104 or open the CT
|
||||
102 application gate between these two steps.
|
||||
|
||||
The current reviewed script has SHA-256
|
||||
`7bc07d03230e208d28465e07bee16370d80293541eeea97f4f28e5d1b946a291`.
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
readonly EXPECTED_CTID="${GHOSTNET_KEYCLOAK_CTID:-102}"
|
||||
readonly EXPECTED_HOSTNAME="${GHOSTNET_KEYCLOAK_HOSTNAME:-keycloak}"
|
||||
readonly EXPECTED_IPV4="${GHOSTNET_KEYCLOAK_IPV4:-10.0.0.4}"
|
||||
readonly BASELINE_HOOK="${GHOSTNET_BASELINE_HOOK:-/usr/local/sbin/ghostnet-lxc-baseline.sh}"
|
||||
readonly SSH_SOURCE_NETWORKS="${GHOSTNET_SSH_SOURCE_NETWORKS:-10.0.0.0/24,10.8.0.0/24}"
|
||||
|
||||
readonly AGENT_USER="ghost-agent"
|
||||
readonly AGENT_UID="1000"
|
||||
readonly ADMIN_USER="ghost"
|
||||
readonly ADMIN_UID="1001"
|
||||
readonly DEPLOY_USER="ansible-deploy"
|
||||
readonly DEPLOY_UID="1002"
|
||||
|
||||
readonly AGENT_PUBLIC_KEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFe5g5D4VZnjhSvnZYJbvTOZKxzGby9QHcq2UAAUEBHw ghost-agent@keycloak.ghostnet'
|
||||
readonly ADMIN_PUBLIC_KEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIuVcr31VDjIHeD+GdDsHhM1zU3xH5zw4XODMeLu5Ctw ghost@keycloak.ghostnet'
|
||||
readonly DEPLOY_PUBLIC_KEY='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICSWvCGDWzUGhXuK65EMZ78SilRdXDSG3ewg768qM3M1 ansible-deploy@ghostnet-ct102'
|
||||
|
||||
log() {
|
||||
printf '[ghostnet-keycloak-bootstrap] %s\n' "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf '[ghostnet-keycloak-bootstrap] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ "${EUID}" -eq 0 ]] || die "must run as root on the Proxmox host"
|
||||
command -v pct >/dev/null 2>&1 || die "pct not found; run this hook on Proxmox"
|
||||
[[ -x "$BASELINE_HOOK" ]] || die "baseline hook is missing or not executable: ${BASELINE_HOOK}"
|
||||
|
||||
ctid="${CTID:-${1:-}}"
|
||||
[[ "$ctid" =~ ^[0-9]+$ ]] || die "CTID must be supplied by Community Scripts or as the first argument"
|
||||
[[ "$ctid" == "$EXPECTED_CTID" ]] || die "refusing CT ${ctid}; expected CT ${EXPECTED_CTID}"
|
||||
|
||||
if [[ -n "${APP:-}" ]]; then
|
||||
[[ "${APP,,}" == "podman" ]] || die "refusing helper application ${APP}; expected Podman"
|
||||
fi
|
||||
if [[ -n "${HN:-}" ]]; then
|
||||
[[ "$HN" == "$EXPECTED_HOSTNAME" ]] || die "refusing helper hostname ${HN}; expected ${EXPECTED_HOSTNAME}"
|
||||
fi
|
||||
if [[ -n "${IP:-}" ]]; then
|
||||
[[ "$IP" == "$EXPECTED_IPV4" || "$IP" == "$EXPECTED_IPV4/"* ]] ||
|
||||
die "refusing helper IPv4 ${IP}; expected ${EXPECTED_IPV4}"
|
||||
fi
|
||||
|
||||
pct config "$ctid" >/dev/null 2>&1 || die "CT ${ctid} does not exist"
|
||||
[[ "$(pct status "$ctid")" == 'status: running' ]] || die "CT ${ctid} is not running"
|
||||
|
||||
log "running reviewed generic LXC baseline for CT ${ctid}"
|
||||
CTID="$ctid" "$BASELINE_HOOK" "$ctid"
|
||||
|
||||
guest_hostname="$(pct exec "$ctid" -- hostname --short)"
|
||||
[[ "$guest_hostname" == "$EXPECTED_HOSTNAME" ]] ||
|
||||
die "guest hostname is ${guest_hostname}; expected ${EXPECTED_HOSTNAME}"
|
||||
|
||||
pct exec "$ctid" -- ip -o -4 address show scope global |
|
||||
grep -Eq "[[:space:]]inet[[:space:]]+${EXPECTED_IPV4//./\\.}/" ||
|
||||
die "guest does not own expected IPv4 ${EXPECTED_IPV4}"
|
||||
|
||||
log "installing deterministic CT 102 access bootstrap"
|
||||
pct exec "$ctid" -- /bin/bash -s -- \
|
||||
"$AGENT_USER" "$AGENT_UID" "$ADMIN_USER" "$ADMIN_UID" \
|
||||
"$DEPLOY_USER" "$DEPLOY_UID" "$SSH_SOURCE_NETWORKS" \
|
||||
"$AGENT_PUBLIC_KEY" "$ADMIN_PUBLIC_KEY" "$DEPLOY_PUBLIC_KEY" <<'GUEST_SCRIPT'
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
agent_user="$1"
|
||||
agent_uid="$2"
|
||||
admin_user="$3"
|
||||
admin_uid="$4"
|
||||
deploy_user="$5"
|
||||
deploy_uid="$6"
|
||||
ssh_source_networks="$7"
|
||||
agent_public_key="$8"
|
||||
admin_public_key="$9"
|
||||
deploy_public_key="${10}"
|
||||
|
||||
log() {
|
||||
printf '[ghostnet-keycloak-bootstrap/guest] %s\n' "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf '[ghostnet-keycloak-bootstrap/guest] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ "${EUID}" -eq 0 ]] || die "guest stage is not running as root"
|
||||
|
||||
validate_existing_user() {
|
||||
local user="$1"
|
||||
local expected_uid="$2"
|
||||
local actual_uid=""
|
||||
local actual_gid=""
|
||||
|
||||
getent passwd "$user" >/dev/null || die "baseline account ${user} is missing"
|
||||
actual_uid="$(id -u "$user")"
|
||||
actual_gid="$(id -g "$user")"
|
||||
[[ "$actual_uid" == "$expected_uid" ]] ||
|
||||
die "${user} has UID ${actual_uid}; expected ${expected_uid}"
|
||||
[[ "$actual_gid" == "$expected_uid" ]] ||
|
||||
die "${user} has GID ${actual_gid}; expected ${expected_uid}"
|
||||
}
|
||||
|
||||
validate_free_identity_slot() {
|
||||
local user="$1"
|
||||
local expected_uid="$2"
|
||||
local owner_match=""
|
||||
|
||||
if getent passwd "$user" >/dev/null; then
|
||||
validate_existing_user "$user" "$expected_uid"
|
||||
return
|
||||
fi
|
||||
|
||||
getent passwd "$expected_uid" >/dev/null &&
|
||||
die "UID ${expected_uid} is assigned to $(getent passwd "$expected_uid" | cut -d: -f1)"
|
||||
getent group "$user" >/dev/null && die "group ${user} exists without its user"
|
||||
getent group "$expected_uid" >/dev/null &&
|
||||
die "GID ${expected_uid} is assigned to $(getent group "$expected_uid" | cut -d: -f1)"
|
||||
|
||||
owner_match="$(find / -xdev \( -uid "$expected_uid" -o -gid "$expected_uid" \) -print -quit 2>/dev/null || true)"
|
||||
[[ -z "$owner_match" ]] ||
|
||||
die "UID/GID ${expected_uid} already owns guest path ${owner_match}"
|
||||
}
|
||||
|
||||
validate_existing_user "$agent_user" "$agent_uid"
|
||||
validate_existing_user "$admin_user" "$admin_uid"
|
||||
validate_free_identity_slot "$deploy_user" "$deploy_uid"
|
||||
|
||||
if ! getent passwd "$deploy_user" >/dev/null; then
|
||||
useradd \
|
||||
--uid "$deploy_uid" \
|
||||
--create-home \
|
||||
--shell /bin/bash \
|
||||
--user-group \
|
||||
"$deploy_user"
|
||||
passwd --lock "$deploy_user" >/dev/null
|
||||
log "created locked ${deploy_user} account with UID/GID ${deploy_uid}"
|
||||
fi
|
||||
|
||||
for user in "$agent_user" "$admin_user" "$deploy_user"; do
|
||||
[[ "$(getent passwd "$user" | cut -d: -f6)" == "/home/${user}" ]] ||
|
||||
die "unexpected home directory for ${user}"
|
||||
[[ "$(getent passwd "$user" | cut -d: -f7)" == "/bin/bash" ]] ||
|
||||
die "unexpected shell for ${user}"
|
||||
[[ ! -L "/home/${user}" ]] || die "home for ${user} is a symlink"
|
||||
[[ ! -L "/home/${user}/.ssh" ]] || die ".ssh for ${user} is a symlink"
|
||||
[[ ! -L "/home/${user}/.ssh/authorized_keys" ]] ||
|
||||
die "authorized_keys for ${user} is a symlink"
|
||||
install -d -o "$user" -g "$user" -m 700 "/home/${user}/.ssh"
|
||||
done
|
||||
|
||||
install_authorized_key() {
|
||||
local user="$1"
|
||||
local options="$2"
|
||||
local public_key="$3"
|
||||
local target="/home/${user}/.ssh/authorized_keys"
|
||||
|
||||
install -o "$user" -g "$user" -m 600 /dev/null "$target"
|
||||
printf '%s %s\n' "$options" "$public_key" >"$target"
|
||||
chown "$user:$user" "$target"
|
||||
chmod 600 "$target"
|
||||
ssh-keygen -l -f "$target" >/dev/null
|
||||
}
|
||||
|
||||
install_authorized_key \
|
||||
"$agent_user" \
|
||||
"restrict,from=\"${ssh_source_networks}\"" \
|
||||
"$agent_public_key"
|
||||
install_authorized_key \
|
||||
"$admin_user" \
|
||||
"from=\"${ssh_source_networks}\",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-user-rc" \
|
||||
"$admin_public_key"
|
||||
install_authorized_key \
|
||||
"$deploy_user" \
|
||||
"restrict,from=\"${ssh_source_networks}\"" \
|
||||
"$deploy_public_key"
|
||||
|
||||
passwd --lock "$agent_user" >/dev/null
|
||||
passwd --lock "$deploy_user" >/dev/null
|
||||
usermod -aG sudo "$admin_user"
|
||||
|
||||
agent_groups="$(id -Gn "$agent_user")"
|
||||
deploy_groups="$(id -Gn "$deploy_user")"
|
||||
[[ "$agent_groups" == "$agent_user" ]] ||
|
||||
die "${agent_user} has unexpected supplementary groups: ${agent_groups}"
|
||||
[[ "$deploy_groups" == "$deploy_user" ]] ||
|
||||
die "${deploy_user} has unexpected supplementary groups: ${deploy_groups}"
|
||||
|
||||
sudoers_target="/etc/sudoers.d/90-ghostnet-ansible-deploy"
|
||||
install -o root -g root -m 440 /dev/null "$sudoers_target"
|
||||
printf '%s ALL=(root) NOPASSWD: ALL\n' "$deploy_user" >"$sudoers_target"
|
||||
chmod 440 "$sudoers_target"
|
||||
visudo -cf "$sudoers_target" >/dev/null
|
||||
|
||||
ssh_dropin="/etc/ssh/sshd_config.d/00-ghostnet-bootstrap.conf"
|
||||
install -o root -g root -m 600 /dev/null "$ssh_dropin"
|
||||
cat >"$ssh_dropin" <<EOF
|
||||
# Managed by ghostnet-keycloak-ansible-bootstrap-hook.sh.
|
||||
PermitRootLogin no
|
||||
PasswordAuthentication no
|
||||
KbdInteractiveAuthentication no
|
||||
PubkeyAuthentication yes
|
||||
AllowUsers ${agent_user} ${admin_user} ${deploy_user}
|
||||
X11Forwarding no
|
||||
AllowAgentForwarding no
|
||||
AllowTcpForwarding no
|
||||
PermitTunnel no
|
||||
EOF
|
||||
chmod 600 "$ssh_dropin"
|
||||
sshd -t
|
||||
|
||||
sshd_effective="$(sshd -T)"
|
||||
grep -qx 'permitrootlogin no' <<<"$sshd_effective" || die "effective PermitRootLogin is not no"
|
||||
grep -qx 'passwordauthentication no' <<<"$sshd_effective" || die "effective PasswordAuthentication is not no"
|
||||
grep -qx 'kbdinteractiveauthentication no' <<<"$sshd_effective" || die "effective KbdInteractiveAuthentication is not no"
|
||||
grep -qx 'pubkeyauthentication yes' <<<"$sshd_effective" || die "effective PubkeyAuthentication is not yes"
|
||||
effective_allowusers="$({
|
||||
awk '
|
||||
$1 == "allowusers" {
|
||||
for (field = 2; field <= NF; field++) {
|
||||
print $field
|
||||
}
|
||||
}
|
||||
' <<<"$sshd_effective"
|
||||
} | LC_ALL=C sort -u)"
|
||||
expected_allowusers="$(printf '%s\n' "$agent_user" "$admin_user" "$deploy_user" | LC_ALL=C sort)"
|
||||
[[ "$effective_allowusers" == "$expected_allowusers" ]] ||
|
||||
die "effective AllowUsers does not match the three managed accounts: ${effective_allowusers//$'\n'/,}"
|
||||
grep -qx 'allowtcpforwarding no' <<<"$sshd_effective" || die "effective AllowTcpForwarding is not no"
|
||||
|
||||
[[ ! -L /root/.ssh ]] || die "root .ssh directory is a symlink"
|
||||
[[ ! -L /root/.ssh/authorized_keys ]] || die "root authorized_keys is a symlink"
|
||||
install -d -o root -g root -m 700 /root/.ssh
|
||||
install -o root -g root -m 600 /dev/null /root/.ssh/authorized_keys
|
||||
|
||||
# A restart is intentional. Debian's socket-activated sshd cannot reliably
|
||||
# rebind port 22 after SIGHUP; the baseline disables ssh.socket first.
|
||||
systemctl restart ssh.service
|
||||
systemctl is-active --quiet ssh.service || die "ssh.service is not active after restart"
|
||||
|
||||
deploy_sudo_uid="$(runuser -u "$deploy_user" -- sudo -n /usr/bin/id -u)"
|
||||
[[ "$deploy_sudo_uid" == "0" ]] || die "${deploy_user} non-interactive sudo failed"
|
||||
if runuser -u "$agent_user" -- sudo -n /usr/bin/true >/dev/null 2>&1; then
|
||||
die "${agent_user} unexpectedly has non-interactive sudo"
|
||||
fi
|
||||
|
||||
agent_password_state="$(passwd -S "$agent_user" | awk '{print $2}')"
|
||||
admin_password_state="$(passwd -S "$admin_user" | awk '{print $2}')"
|
||||
deploy_password_state="$(passwd -S "$deploy_user" | awk '{print $2}')"
|
||||
[[ "$agent_password_state" == "L" ]] || die "${agent_user} password is not locked"
|
||||
[[ "$deploy_password_state" == "L" ]] || die "${deploy_user} password is not locked"
|
||||
[[ "$admin_password_state" == "L" || "$admin_password_state" == "P" ]] ||
|
||||
die "${admin_user} has unsafe password state ${admin_password_state}"
|
||||
|
||||
log "SSH host key: $(ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub)"
|
||||
log "${agent_user}: $(id "$agent_user") / password ${agent_password_state}"
|
||||
log "${admin_user}: $(id "$admin_user") / password ${admin_password_state}"
|
||||
log "${deploy_user}: $(id "$deploy_user") / password ${deploy_password_state}"
|
||||
log "access bootstrap verified; Ansible may connect after independent host-key validation"
|
||||
GUEST_SCRIPT
|
||||
|
||||
log "CT ${ctid} access bootstrap completed"
|
||||
log "operator action: set the ${ADMIN_USER} sudo password interactively with 'pct exec ${ctid} -- passwd ${ADMIN_USER}'"
|
||||
log "do not store that password in this hook, shell history, Ansible inventory, or Git"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user