CT-102 Keycloak: Migration nach Podman und Ansible abgeschlossen und gehärtet.

This commit is contained in:
2026-10-14 15:01:14 +02:00
parent b40f726d22
commit 00ed091890
125 changed files with 6610 additions and 90 deletions
+21 -12
View File
@@ -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
View File
@@ -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:
+14 -20
View File
@@ -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
+7 -1
View File
@@ -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
+10
View File
@@ -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.
@@ -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)
@@ -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.
@@ -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
@@ -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.
@@ -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.
+9
View File
@@ -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]
+14
View File
@@ -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.
+13
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
}
}
{{ '' }}
@@ -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
@@ -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
@@ -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