Compare commits
@@ -59,6 +59,25 @@ SSHKV_BREAKGLASS_PASSWORD=
|
|||||||
# --- Worker --------------------------------------------------------------------
|
# --- Worker --------------------------------------------------------------------
|
||||||
# Zeitplan fuer den Abgleich aller Hosts (cron)
|
# Zeitplan fuer den Abgleich aller Hosts (cron)
|
||||||
SSHKV_SYNC_CRON=30 2 * * *
|
SSHKV_SYNC_CRON=30 2 * * *
|
||||||
|
# Taegliche Zusammenfassung der Benachrichtigungen (cron, Europe/Berlin, #028). Geaenderte
|
||||||
|
# Host-Keys meldet der Worker unabhaengig davon alle fuenf Minuten. Kanaele und Anlaesse
|
||||||
|
# stellt ein Admin unter /einstellungen ein.
|
||||||
|
SSHKV_MELDUNG_CRON=0 7 * * *
|
||||||
|
|
||||||
|
# --- Syslog (optional, #027) ---------------------------------------------------
|
||||||
|
# Jeder Protokolleintrag geht zusaetzlich als RFC-5424-Zeile an einen Syslog-Server – die
|
||||||
|
# Kopie ausserhalb von sshkv. Ohne SSHKV_SYSLOG_HOST ist die Weiterleitung aus. Es gehen nur
|
||||||
|
# Akteur, Aktion, Ergebnis, Host, Zielbenutzer, Fingerprint und Hash hinaus, nie Details/Diff.
|
||||||
|
# Web und Worker protokollieren beide; beide brauchen die Variablen.
|
||||||
|
# SSHKV_SYSLOG_HOST=syslog.intern
|
||||||
|
# Standard 514
|
||||||
|
# SSHKV_SYSLOG_PORT=514
|
||||||
|
# udp (Standard) oder tcp (Oktettzaehlung nach RFC 6587)
|
||||||
|
# SSHKV_SYSLOG_PROTOKOLL=udp
|
||||||
|
# APP-NAME im Syslog-Kopf (Standard sshkv)
|
||||||
|
# SSHKV_SYSLOG_APPNAME=sshkv
|
||||||
|
# Facility (Standard authpriv; auch auth, daemon, user, local0 … local7)
|
||||||
|
# SSHKV_SYSLOG_FACILITY=authpriv
|
||||||
|
|
||||||
# --- Integrationstests (#011) ---------------------------------------------------
|
# --- Integrationstests (#011) ---------------------------------------------------
|
||||||
# SSH-Testziele aus docker-compose.test.yml. Die Compose-Datei liest dieselben Variablen
|
# SSH-Testziele aus docker-compose.test.yml. Die Compose-Datei liest dieselben Variablen
|
||||||
@@ -70,3 +89,8 @@ SSHKV_TEST_ROOT_PASSWORT=sshkv-test
|
|||||||
SSHKV_TEST_TEST_PASSWORT=sshkv-test
|
SSHKV_TEST_TEST_PASSWORT=sshkv-test
|
||||||
# Mock-OIDC (Port-Bindung)
|
# Mock-OIDC (Port-Bindung)
|
||||||
SSHKV_TEST_OIDC=127.0.0.1:8090
|
SSHKV_TEST_OIDC=127.0.0.1:8090
|
||||||
|
# Mailpit fuer tests/integration/benachrichtigung.test.ts (#028), getrennt zu starten:
|
||||||
|
# docker run -d --name sshkv-mailpit -e MP_SMTP_DISABLE_RDNS=true \
|
||||||
|
# -p 127.0.0.1:1026:1025 -p 127.0.0.1:8026:8025 axllent/mailpit
|
||||||
|
SSHKV_TEST_SMTP=127.0.0.1:1026
|
||||||
|
SSHKV_TEST_MAILPIT_API=http://127.0.0.1:8026
|
||||||
|
|||||||
@@ -8,13 +8,71 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
- Geführte Rotation des Deployment-Keys in fünf Schritten (erzeugen, verteilen, prüfen, umstellen, aufräumen) – kein Host steht dabei ohne funktionierenden Key da; nicht erreichbare Hosts lassen sich bewusst übergehen (#031)
|
||||||
|
- Laufende Schlüsselrotation abbrechen – der Nachfolger wird per Dry-Run wieder entfernt (#032)
|
||||||
|
- „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032)
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
- Protokoll-IDs werden lückenlos vergeben (#032)
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
- Massenwiderruf lehnte im Browser erzeugte Schlüssel ab (IDs mit Bindestrich) (#032)
|
||||||
|
- Hinweis nach verweigerter Bestätigung nannte beim Massenwiderruf den falschen Knopf (#032)
|
||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.5.0] – 2026-10-12
|
||||||
|
|
||||||
|
Phase 5: Übersicht, Audit-Log, Benachrichtigungen, Handbuch und Betrieb.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Übersicht mit echten Zahlen: Kennzahlen, Hosts mit Abweichung, bald ablaufende und abgelaufene Schlüssel, letzte Aktionen (#026)
|
||||||
|
- Audit-Log: Filter, aufklappbare Einträge mit Diff und Hashes, Prüfung der Hash-Kette, CSV-Export (gestreamt), optionale Syslog-Kopie nach RFC 5424 (#027)
|
||||||
|
- Benachrichtigungen per E-Mail (Microsoft 365 SMTP vorbelegt) und Webhook mit Signatur: tägliche Zusammenfassung zu Drift, Ablauf und nicht erreichbaren Hosts, geänderte Host-Keys sofort; Testversand, Versandprotokoll ohne Doppelmeldungen; Einstellungsseite (#028)
|
||||||
|
- Handbuch mit Bildern, Installation mit authentik und Reverse Proxy, Bedrohungsanalyse mit Notfallplan (#029)
|
||||||
|
- `deploy/sichern.sh` und `deploy/wiederherstellen.sh` – Wiederherstellung auf einem Wegwerf-Stapel geprobt (#029)
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Der Worker startet nicht, wenn der Master-Key nicht zur Datenbank passt (#029)
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Sicherung ohne Master-Key, nur mit Prüfwert; Wiederherstellung bricht vor jeder Änderung ab, wenn der Master-Key nicht passt, und läuft in einer Transaktion
|
||||||
|
- SMTP-Passwort und Webhook-Geheimnis verschlüsselt gespeichert und nie wieder angezeigt; Webhook nur über https, ohne Weiterleitungen
|
||||||
|
- Syslog-Kopie enthält nie Details oder Diffs
|
||||||
|
|
||||||
|
## [0.4.0] – 2026-10-12
|
||||||
|
|
||||||
|
Phase 4: Abgleich, Import, Rotation, Widerruf und Zuweisungsmatrix.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Abgleich je Host und nächtlich per Zeitplan (`SSHKV_SYNC_CRON`): erkennt fehlende Schlüssel, von Hand geänderte Blöcke, unbekannte Schlüssel, falsche Rechte, beschädigte Blöcke und veränderte Private Keys; Befunde öffnen und schließen sich selbst. Hostseite mit Abweichungen und Ist-Zustand je Benutzer, „Jetzt abgleichen“ auch auf der Übersicht (#021)
|
||||||
|
- Unbekannte Schlüssel übernehmen: als bekannt aufnehmen oder per Dry-Run in den verwalteten Block holen (#022)
|
||||||
|
- Geführte Rotation: neuer Schlüssel an allen Stellen, Verbindungstests, erst dann wird der alte entfernt; jederzeit fortsetzbar, Rotationskette auf der Schlüsselseite (#023)
|
||||||
|
- Widerruf mit einem Klick und Massenwiderruf: Public Key, Private Key, config- und known_hosts-Einträge entfernen, optional Zielkonten; Zwischenzustand, solange ein Host nicht erreicht wird; abgelaufene Schlüssel mit Hinweis (#024)
|
||||||
|
- Zuweisungsmatrix Schlüssel × Host mit Filtern, Zell-Dialog zum Zuweisen und CSV-Export (#025)
|
||||||
|
- Skript zum Abgleich der Issues nach Gitea (`pnpm issues:sync`, #030 – Zugang steht noch aus)
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Abgleich liest Dateien als der jeweilige Benutzer und ändert auf dem Host nichts
|
||||||
|
- Zeilen außerhalb des verwalteten Blocks entfernt sshkv nur bei ausdrücklich übernommenen Schlüsseln
|
||||||
|
- Ein Widerruf gilt erst als abgeschlossen, wenn der Schlüssel wirklich überall entfernt ist; eine Rotation entfernt den alten Schlüssel erst nach erfolgreichen Verbindungstests
|
||||||
|
- CSV-Export entschärft Werte, die eine Tabellenkalkulation als Formel lesen würde
|
||||||
|
|
||||||
|
## [0.3.0] – 2026-10-12
|
||||||
|
|
||||||
|
Phase 3: Host-zu-Host – Private Keys, Zielkonten, Vorlagen und Assistent.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Host-zu-Host-Assistent: Vorlage wählen, Host A und B, Zielbenutzer, Einschränkungen und `~/.ssh/config`-Alias, Fingerprint von B bestätigen, Dry-Run, ausrollen, Verbindung testen – in einem Durchgang (#020)
|
||||||
|
- Zielbenutzer (z. B. `deploy`) auf dem Ziel anlegen, mit sudoers-Datei für bestimmte Befehle oder vollen Zugriff; Vorschau vor dem Anlegen, Prüfung mit `visudo -c` (#018)
|
||||||
|
- Vorlagen verwalten: eingebaute „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“, eigene anlegen und archivieren, Live-Vorschau der Platzhalter und des sudoers-Inhalts (#019)
|
||||||
|
- Übersicht verlinkt den Assistenten
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Neue Benutzer mit sudo nur durch Admins mit frischer Bestätigung
|
||||||
|
- sshkv überschreibt keine fremde sudoers-Datei und fasst Systemkonten nicht an; ein Widerruf löscht nur Benutzer, die sshkv selbst angelegt hat
|
||||||
|
- Platzhalter-Werte können keine zusätzlichen authorized_keys-Optionen einschleusen
|
||||||
|
|
||||||
## [0.2.0] – 2026-10-12
|
## [0.2.0] – 2026-10-12
|
||||||
|
|
||||||
Phase 2: Schlüssel, Personen und Ausrollen von Public Keys – dazu vorgezogen aus Phase 3 das Ausrollen von Private Keys.
|
Phase 2: Schlüssel, Personen und Ausrollen von Public Keys – dazu vorgezogen aus Phase 3 das Ausrollen von Private Keys.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt (Wandzeit):** 2 h 25 min
|
**Gesamt (Wandzeit):** 5 h 20 min
|
||||||
**Summe der Issue-Zeiten:** 7 h 08 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011 und #012–#014.
|
**Summe der Issue-Zeiten:** 15 h 13 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032.
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -25,14 +25,30 @@
|
|||||||
| #015 authorized_keys-Parser | 0 h 15 min |
|
| #015 authorized_keys-Parser | 0 h 15 min |
|
||||||
| #016 Public Key ausrollen | 0 h 40 min |
|
| #016 Public Key ausrollen | 0 h 40 min |
|
||||||
| #017 Private Key ausrollen | 0 h 45 min |
|
| #017 Private Key ausrollen | 0 h 45 min |
|
||||||
| #018 Zielbenutzer und sudoers (Teil) | 0 h 10 min |
|
| #018 Zielbenutzer und sudoers | 0 h 30 min |
|
||||||
| #019 Vorlagen (Teil) | 0 h 10 min |
|
| #019 Vorlagen | 0 h 20 min |
|
||||||
| #020 Verbindungstest (Teil) | 0 h 05 min |
|
| #020 Host-zu-Host-Assistent | 0 h 50 min |
|
||||||
|
| #021 Abgleich und Drift-Erkennung | 0 h 45 min |
|
||||||
|
| #022 Import bestehender Schlüssel | 0 h 20 min |
|
||||||
|
| #023 Geführte Rotation | 0 h 20 min |
|
||||||
|
| #024 Widerruf und Massenwiderruf | 0 h 25 min |
|
||||||
|
| #025 Zuweisungsmatrix | 0 h 30 min |
|
||||||
|
| #026 Übersicht | 0 h 25 min |
|
||||||
|
| #027 Protokollansicht, CSV, Syslog | 0 h 20 min |
|
||||||
|
| #028 Benachrichtigungen | 0 h 30 min |
|
||||||
|
| #029 Handbuch und Betriebsdokumentation | 0 h 50 min |
|
||||||
|
| #030 Gitea-Abgleich | 0 h 05 min |
|
||||||
|
| #031 Rotation des Deployment-Keys | 1 h 10 min |
|
||||||
|
| #032 Nacharbeiten Phase 4/5 | 0 h 45 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
| v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen**
|
| v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen**
|
||||||
| v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen**
|
| v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen**
|
||||||
|
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
||||||
|
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||||
|
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||||
|
| v1.0.0 | 0 h 55 min (Wandzeit) | in Arbeit – Abnahme steht aus
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,3 +100,72 @@
|
|||||||
- **Private-Key-Ziele im Dry-Run nur für Admins:** Sonst hätte ein Operator einen vorbereiteten Private Key ausrollen lassen können (Freigabe Frage 3).
|
- **Private-Key-Ziele im Dry-Run nur für Admins:** Sonst hätte ein Operator einen vorbereiteten Private Key ausrollen lassen können (Freigabe Frage 3).
|
||||||
- **Keine Sicherungskopie alter Private Keys** auf dem Host; config-Block am Dateianfang, weil ssh den ersten Treffer nimmt.
|
- **Keine Sicherungskopie alter Private Keys** auf dem Host; config-Block am Dateianfang, weil ssh den ersten Treffer nimmt.
|
||||||
- **Ein Hook `useVorgang` für alle:** zwei parallel entstandene Fassungen zusammengeführt, beide Schnittstellen bleiben.
|
- **Ein Hook `useVorgang` für alle:** zwei parallel entstandene Fassungen zusammengeführt, beide Schnittstellen bleiben.
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 3 · 11:06–11:36
|
||||||
|
**Issue:** #018–#020
|
||||||
|
**Version:** 0.3.0
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- #019 Vorlagen-Oberfläche (Liste, Formular, Live-Vorschau) von einem Sub-Agenten, gemergt
|
||||||
|
- #018 Zielkonten als Vorgänge mit Migration, Herkunft gemerkt; Integrationstest anlegen → entfernen
|
||||||
|
- #020 Host-zu-Host-Assistent mit Vorlagen, wiederaufnehmbarer Ausführung und Verbindungstest; E2E mit „Deployment-Zugang“
|
||||||
|
- Stand: 423 Unit-/DB-Tests, 31 Integrationstests, 16 E2E-Abläufe grün
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **Migration auf Ortszeit-Reihenfolge umbenannt** (`20261012110000`): Prisma vergibt UTC-Zeitstempel, die Migrationen des Agenten trugen Ortszeit – sonst stünde die neue vor älteren.
|
||||||
|
- **Bestätigung im neuen Tab:** Der Assistent hält seine Eingaben im Speicher; eine Umleitung nach `/bestaetigen` hätte sie gelöscht.
|
||||||
|
- **Ausführung des Assistenten als Kette im Klick** statt als Hook-Zustandsmaschine – einfacher, und erledigte Schritte lassen sich beim Fortsetzen überspringen.
|
||||||
|
- **Prozesse stoppen nur über ein Skript, das Programm und Arbeitsverzeichnis prüft:** Zweimal hatte ein zu breites Suchmuster die eigene Shell getroffen.
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 4 · 12:50–13:35
|
||||||
|
**Issue:** #021–#025, #030
|
||||||
|
**Version:** 0.4.0
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- Zweites, getrenntes Paar SSH-Testziele (`sshkv-test-b`, Ports 2231/2232, Mock-OIDC 8091) für den Rotation/Widerruf-Agenten, damit Integrationstests sich nicht gegenseitig zurücksetzen
|
||||||
|
- #021 Abgleich mit reiner Befundlogik, Worker-Ablauf, Zeitplan; #022 Übernahme mit neuem Feld `ausserhalbEntfernen` – Integrationstests und E2E
|
||||||
|
- #023/#024 Rotation und Widerruf (Sub-Agent) mit Zwischenzustand `widerrufEingeleitetAm`; #025 Zuweisungsmatrix (Sub-Agent)
|
||||||
|
- #030 Gitea-Skript bereitgelegt (Trockenlauf läuft)
|
||||||
|
- Stand: 470 Unit-/DB-Tests, 38 Integrationstests, 21 E2E-Abläufe grün
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **Abgleich ändert nichts auf dem Host:** Er meldet nur; Korrekturen laufen über Dry-Run und Ausrollen, damit ein Mensch sie sieht.
|
||||||
|
- **Bekannte Schlüssel außerhalb des Blocks sind kein Befund** – nur unbekannte. Sonst meldete jeder bewusst stehen gelassene Admin-Key dauerhaft Drift.
|
||||||
|
- **Widerruf mit Zwischenzustand:** Ein Schlüssel wird erst WIDERRUFEN, wenn er überall entfernt ist; ein nicht erreichbarer Host lässt ihn erkennbar „unvollständig“.
|
||||||
|
- **Rotation entfernt den alten Schlüssel erst nach erfolgreichen Verbindungstests**; doppelte Aliase in `~/.ssh/config`: die jüngste Zuweisung gewinnt.
|
||||||
|
- **Migrationszeitstempel in Ortszeit-Reihenfolge** auch bei parallel entstandenen Migrationen (`…130000` Agent, `…131000` Hauptsitzung).
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 5 · 16:27–17:12
|
||||||
|
**Issue:** #026–#029
|
||||||
|
**Version:** 0.5.0
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- #026 Übersicht aus der Datenbank; lesbare Aktionstexte aus #027
|
||||||
|
- #027 Audit-Log-Ansicht, Kettenprüfung, CSV, Syslog (Sub-Agent)
|
||||||
|
- #028 Benachrichtigungen und Einstellungsseite (Sub-Agent, eigener Mailpit und eigene Testziele)
|
||||||
|
- #029 Handbuch (12 Kapitel mit Bildern), Installation, Bedrohungsanalyse, Sicherung; Sicherung/Wiederherstellung geprobt
|
||||||
|
- Merge-Konflikte (Compose, Screenshot-Skript, Worker-Einstieg) zusammengeführt; der strukturelle Test für Aktionstexte fand vier neue Aktionen ohne Text
|
||||||
|
- Stand: 858 Unit-/DB-Tests, 43 Integrationstests, 24 E2E-Abläufe grün
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **Wiederherstellung wirklich geprobt** statt nur beschrieben – sie fand zwei Fehler, die im Ernstfall die Wiederherstellung verhindert hätten.
|
||||||
|
- **Master-Key nicht in die Sicherung,** nur ein Prüfwert; Worker verweigert den Start mit falschem Master-Key.
|
||||||
|
- **Benachrichtigungen lesen ihre Anlässe aus der Datenbank** statt sich in den Abgleich einzuhängen; Doppelmeldungen verhindert ein Versandprotokoll mit Fingerabdruck je Anlass.
|
||||||
|
- **Keine 1.0.0 ohne Abnahme:** 0.5.0 schließt die fünf Phasen; authentik, Gitea und ein Lauf auf echten Hosts stehen noch aus.
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 6 · 17:12–18:07
|
||||||
|
**Issue:** #030–#032
|
||||||
|
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- Remote-Repo in Gitea: `main` und Tags v0.1.0–v0.5.0 gepusht, Issues abgeglichen (Basic-Auth, weil die hinterlegten Zugangsdaten ein Passwort sind)
|
||||||
|
- #031 Geführte Rotation des Deployment-Keys: Service, Worker-Schritte, Seite mit Schrittleiste, Integrationstest gegen beide Testziele, E2E
|
||||||
|
- #032 Nacharbeiten (Sub-Agent): lückenlose Protokoll-IDs, Rotation abbrechen, Anker „Zum Dry-Run“, Massenwiderruf-E2E – der fand, dass der Massenwiderruf UUIDs mit Bindestrich abwies
|
||||||
|
- Stand: 876 Unit-/DB-Tests, 46 Integrationstests, 27 E2E-Abläufe grün
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **Der Nachfolger des Deployment-Keys ist ein eigener Schlüssel,** der während der Rotation über `sollFuer()` in Block und Pflichtliste steht – so verteilt ihn der normale Dry-Run, und der Aussperrschutz deckt beide Keys.
|
||||||
|
- **Umstellen erst nach Prüfung** auf allen Hosts; ein Host lässt sich nur bewusst und protokolliert übergehen.
|
||||||
|
- **Rotation abbrechen nutzt den vorhandenen Widerruf** statt eines neuen Zustands (#032).
|
||||||
|
- **Protokoll-ID nur noch aus dem Trigger** unter der Advisory-Sperre – keine Lücken mehr durch verworfene Sequenzwerte.
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ Selbst gehostete Weboberfläche zur zentralen Verwaltung von SSH-Schlüsseln: Sc
|
|||||||
erzeugen und verwalten, Public Keys in `authorized_keys` verteilen, Private Keys gezielt auf
|
erzeugen und verwalten, Public Keys in `authorized_keys` verteilen, Private Keys gezielt auf
|
||||||
andere Hosts ausrollen, Ist- und Soll-Zustand abgleichen – alles revisionssicher protokolliert.
|
andere Hosts ausrollen, Ist- und Soll-Zustand abgleichen – alles revisionssicher protokolliert.
|
||||||
|
|
||||||
> **Stand 0.1.0 (Phase 1):** Grundgerüst, Anmeldung (OIDC + Notfall-Admin mit TOTP), Hosts und
|
> **Stand 0.5.0:** alle fünf Phasen aus [`docs/PLAN.md`](docs/PLAN.md) umgesetzt – Hosts mit Host-Key-Prüfung
|
||||||
> Hostgruppen, Host-Key-Prüfung (TOFU) und Bootstrap neuer Hosts. Schlüsselverwaltung und
|
> und Bootstrap, Schlüssel, Ausrollen von Public und Private Keys mit Dry-Run, Host-zu-Host-Assistent,
|
||||||
> Ausrollen folgen in Phase 2 und 3 – siehe [`docs/PLAN.md`](docs/PLAN.md).
|
> Abgleich, Rotation, Widerruf, Zuweisungsmatrix, Audit-Log, Benachrichtigungen. 1.0.0 nach der Abnahme.
|
||||||
|
|
||||||
## Sicherheit vorab
|
## Sicherheit vorab
|
||||||
|
|
||||||
@@ -18,7 +18,15 @@ SSHKeyVault hat **root-Zugriff auf alle verwalteten Hosts**. Deshalb:
|
|||||||
- Private Keys liegen nur verschlüsselt in der Datenbank (AES-256-GCM, Envelope Encryption);
|
- Private Keys liegen nur verschlüsselt in der Datenbank (AES-256-GCM, Envelope Encryption);
|
||||||
entschlüsseln kann ausschließlich der Worker, der Webprozess hat den Master-Key nicht.
|
entschlüsseln kann ausschließlich der Worker, der Webprozess hat den Master-Key nicht.
|
||||||
|
|
||||||
Mehr dazu in [`docs/PLAN.md`](docs/PLAN.md), Abschnitt 3.
|
Mehr dazu in [`docs/betrieb/sicherheit.md`](docs/betrieb/sicherheit.md) (mit Bedrohungsanalyse und
|
||||||
|
Notfallplan) und [`docs/betrieb/sicherung.md`](docs/betrieb/sicherung.md).
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
- [Handbuch](docs/anleitung/README.md) – Bedienung Schritt für Schritt mit Bildern
|
||||||
|
- [Installation und Betrieb](docs/betrieb/installation.md) – Docker Compose, authentik, Reverse Proxy
|
||||||
|
- [Sicherung und Wiederherstellung](docs/betrieb/sicherung.md) – `deploy/sichern.sh`, `deploy/wiederherstellen.sh`
|
||||||
|
- [Sicherheit](docs/betrieb/sicherheit.md) – Aufbau, Bedrohungsanalyse, Notfallplan
|
||||||
|
|
||||||
## Installation (Docker Compose)
|
## Installation (Docker Compose)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# SSHKeyVault sichern (#029).
|
||||||
|
#
|
||||||
|
# bash deploy/sichern.sh # nach /var/backups/sshkv
|
||||||
|
# bash deploy/sichern.sh /mnt/sicherung/sshkv # anderes Ziel
|
||||||
|
#
|
||||||
|
# Sichert die Datenbank (pg_dump mit --clean, damit sie sich auch in eine nicht leere
|
||||||
|
# Datenbank zurueckspielen laesst) und legt einen Pruefwert des Master-Keys daneben.
|
||||||
|
#
|
||||||
|
# Den MASTER-KEY selbst sichert dieses Skript absichtlich NICHT mit: Datenbank und Master-Key
|
||||||
|
# zusammen geben alle Private Keys preis. Den Master-Key einmalig und GETRENNT aufbewahren
|
||||||
|
# (Passwortmanager, Tresor, zweites Medium) – ohne ihn ist jede Sicherung wertlos.
|
||||||
|
# Siehe docs/betrieb/sicherung.md.
|
||||||
|
|
||||||
|
set -o errexit
|
||||||
|
set -o nounset
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
cd "$VERZEICHNIS"
|
||||||
|
|
||||||
|
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
|
||||||
|
gelb() { printf '\033[1;33m! %s\033[0m\n' "$*"; }
|
||||||
|
fehler() {
|
||||||
|
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ZIEL="${1:-/var/backups/sshkv}"
|
||||||
|
BEHALTEN="${SSHKV_SICHERUNGEN_BEHALTEN:-14}"
|
||||||
|
STAND=$(date +%Y%m%d-%H%M%S)
|
||||||
|
|
||||||
|
mkdir -p "$ZIEL"
|
||||||
|
chmod 0700 "$ZIEL"
|
||||||
|
|
||||||
|
# Datenbank: als Klartext-SQL, gzip-komprimiert. Enthaelt nur verschluesselte Private Keys.
|
||||||
|
# Nur das Schema public: Die Warteschlange (Schema pgboss) legt der Worker beim Start neu an,
|
||||||
|
# und ihre partitionierten Tabellen lassen sich mit --clean nicht zurueckspielen (in der
|
||||||
|
# Wiederherstellungsprobe aufgefallen).
|
||||||
|
docker compose exec -T db pg_dump -U sshkv --clean --if-exists --no-owner --schema=public sshkv \
|
||||||
|
| gzip -9 > "${ZIEL}/datenbank-${STAND}.sql.gz" \
|
||||||
|
|| fehler "pg_dump ist fehlgeschlagen. Läuft der Dienst db?"
|
||||||
|
[ -s "${ZIEL}/datenbank-${STAND}.sql.gz" ] || fehler "Die Sicherung ist leer."
|
||||||
|
chmod 0600 "${ZIEL}/datenbank-${STAND}.sql.gz"
|
||||||
|
gruen "Datenbank gesichert: ${ZIEL}/datenbank-${STAND}.sql.gz"
|
||||||
|
|
||||||
|
# Pruefwert des Master-Keys: verraet nichts ueber den Schluessel, zeigt beim Zurueckspielen
|
||||||
|
# aber, ob der richtige Master-Key bereitliegt.
|
||||||
|
ENV_DATEI="${SSHKV_ENV_DATEI:-.env}"
|
||||||
|
SCHLUESSEL="$(grep -E '^SSHKV_MASTER_KEY_HOST_FILE=' "$ENV_DATEI" 2>/dev/null | cut -d= -f2- || true)"
|
||||||
|
SCHLUESSEL="${SCHLUESSEL:-./geheim/master.key}"
|
||||||
|
if [ -r "$SCHLUESSEL" ]; then
|
||||||
|
sha256sum "$SCHLUESSEL" | cut -d' ' -f1 > "${ZIEL}/master-key-pruefwert-${STAND}.txt"
|
||||||
|
chmod 0600 "${ZIEL}/master-key-pruefwert-${STAND}.txt"
|
||||||
|
gruen "Prüfwert des Master-Keys abgelegt (nicht der Schlüssel selbst)."
|
||||||
|
else
|
||||||
|
gelb "Master-Key ${SCHLUESSEL} nicht lesbar – kein Prüfwert abgelegt (mit sudo ausführen?)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Alte Sicherungen aufraeumen (samt Pruefwert).
|
||||||
|
ls -1t "${ZIEL}"/datenbank-*.sql.gz 2>/dev/null | tail -n +"$((BEHALTEN + 1))" | while read -r alt; do
|
||||||
|
alter_stand=$(basename "$alt" .sql.gz)
|
||||||
|
alter_stand=${alter_stand#datenbank-}
|
||||||
|
rm -f -- "$alt" "${ZIEL}/master-key-pruefwert-${alter_stand}.txt"
|
||||||
|
done
|
||||||
|
|
||||||
|
gelb "Den Master-Key getrennt aufbewahren – er gehört NICHT neben diese Sicherung."
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# SSHKeyVault aus einer Sicherung wiederherstellen (#029).
|
||||||
|
#
|
||||||
|
# bash deploy/wiederherstellen.sh /var/backups/sshkv # jüngste Sicherung
|
||||||
|
# bash deploy/wiederherstellen.sh /var/backups/sshkv 20261012-170000
|
||||||
|
# bash deploy/wiederherstellen.sh … --ja # ohne Rückfrage
|
||||||
|
#
|
||||||
|
# Voraussetzung: Der zur Sicherung gehoerende Master-Key liegt an der in .env genannten Stelle
|
||||||
|
# (SSHKV_MASTER_KEY_HOST_FILE). Das Skript vergleicht ihn mit dem Pruefwert der Sicherung und
|
||||||
|
# bricht ab, wenn er nicht passt – sonst laeuft sshkv ohne lesbare Private Keys an.
|
||||||
|
|
||||||
|
set -o errexit
|
||||||
|
set -o nounset
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
cd "$VERZEICHNIS"
|
||||||
|
|
||||||
|
blau() { printf '\033[1;36m› %s\033[0m\n' "$*"; }
|
||||||
|
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
|
||||||
|
fehler() {
|
||||||
|
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
QUELLE="${1:-/var/backups/sshkv}"
|
||||||
|
STAND=""
|
||||||
|
OHNE_RUECKFRAGE="nein"
|
||||||
|
for argument in "${@:2}"; do
|
||||||
|
if [ "$argument" = "--ja" ]; then OHNE_RUECKFRAGE="ja"; else STAND="$argument"; fi
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -d "$QUELLE" ] || fehler "Kein Sicherungsordner unter ${QUELLE}."
|
||||||
|
if [ -z "$STAND" ]; then
|
||||||
|
DATENBANK=$(ls -1t "${QUELLE}"/datenbank-*.sql.gz 2>/dev/null | head -n1 || true)
|
||||||
|
else
|
||||||
|
DATENBANK="${QUELLE}/datenbank-${STAND}.sql.gz"
|
||||||
|
fi
|
||||||
|
[ -n "${DATENBANK:-}" ] && [ -f "$DATENBANK" ] || fehler "Keine Datenbanksicherung in ${QUELLE} gefunden."
|
||||||
|
PRUEFWERT="${DATENBANK/datenbank-/master-key-pruefwert-}"
|
||||||
|
PRUEFWERT="${PRUEFWERT/.sql.gz/.txt}"
|
||||||
|
|
||||||
|
ENV_DATEI="${SSHKV_ENV_DATEI:-.env}"
|
||||||
|
SCHLUESSEL="$(grep -E '^SSHKV_MASTER_KEY_HOST_FILE=' "$ENV_DATEI" 2>/dev/null | cut -d= -f2- || true)"
|
||||||
|
SCHLUESSEL="${SCHLUESSEL:-./geheim/master.key}"
|
||||||
|
if [ -f "$PRUEFWERT" ]; then
|
||||||
|
[ -r "$SCHLUESSEL" ] || fehler "Master-Key ${SCHLUESSEL} nicht lesbar (mit sudo ausführen?)."
|
||||||
|
[ "$(sha256sum "$SCHLUESSEL" | cut -d' ' -f1)" = "$(cat "$PRUEFWERT")" ] \
|
||||||
|
|| fehler "Der Master-Key unter ${SCHLUESSEL} gehört NICHT zu dieser Sicherung. Abbruch."
|
||||||
|
gruen "Master-Key passt zur Sicherung."
|
||||||
|
else
|
||||||
|
printf '\033[1;33m! Kein Prüfwert zur Sicherung – der Worker prüft den Master-Key beim Start.\033[0m\n'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\033[1;33m! Achtung: Der aktuelle Datenbestand wird durch die Sicherung ersetzt.\033[0m\n'
|
||||||
|
echo " Sicherung: $(basename "$DATENBANK")"
|
||||||
|
if [ "$OHNE_RUECKFRAGE" != "ja" ]; then
|
||||||
|
read -r -p "Wirklich zurückspielen? [tippen Sie ja] " antwort
|
||||||
|
[ "$antwort" = "ja" ] || fehler "Abgebrochen."
|
||||||
|
fi
|
||||||
|
|
||||||
|
blau "Weboberfläche und Worker anhalten …"
|
||||||
|
docker compose stop web worker
|
||||||
|
blau "Datenbank zurückspielen …"
|
||||||
|
# Eine Transaktion: scheitert etwas, bleibt der alte Stand vollstaendig erhalten (in der
|
||||||
|
# Wiederherstellungsprobe hatte ein Abbruch ohne Transaktion das Schema pgboss halb geloescht).
|
||||||
|
gunzip -c "$DATENBANK" | docker compose exec -T db psql -U sshkv -d sshkv -v ON_ERROR_STOP=1 --single-transaction -q >/dev/null \
|
||||||
|
|| fehler "Zurückspielen fehlgeschlagen. Web und Worker sind angehalten – nach Klärung: docker compose up -d"
|
||||||
|
# Die Warteschlange gehoert nicht zur Sicherung: frisch anlegen lassen (macht der Worker beim
|
||||||
|
# Start), offene Vorgaenge abbrechen – ihre Jobs sind damit weg.
|
||||||
|
docker compose exec -T db psql -U sshkv -d sshkv -q -c "DROP SCHEMA IF EXISTS pgboss CASCADE" >/dev/null
|
||||||
|
docker compose exec -T db psql -U sshkv -d sshkv -q -c \
|
||||||
|
"UPDATE \"Vorgang\" SET status = 'ABGEBROCHEN', fehler = 'Durch Wiederherstellung abgebrochen.', \"beendetAm\" = now() WHERE status IN ('WARTEND','LAEUFT')" >/dev/null
|
||||||
|
blau "Dienste starten …"
|
||||||
|
docker compose up -d --wait web worker
|
||||||
|
sleep 3
|
||||||
|
if docker compose logs --tail 20 worker 2>&1 | grep -q "passt nicht zur Datenbank"; then
|
||||||
|
fehler "Der Worker meldet: Master-Key passt nicht zur Datenbank. Siehe docker compose logs worker."
|
||||||
|
fi
|
||||||
|
gruen "Wiederhergestellt aus $(basename "$DATENBANK")."
|
||||||
@@ -64,6 +64,15 @@ services:
|
|||||||
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
|
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
|
||||||
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
||||||
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
||||||
|
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
|
||||||
|
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
|
||||||
|
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
|
||||||
|
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
|
||||||
|
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
|
||||||
|
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
|
||||||
|
# Nur zur Anzeige unter /einstellungen; ausgefuehrt werden die Zeitplaene im Worker.
|
||||||
|
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
|
||||||
|
SSHKV_MELDUNG_CRON: ${SSHKV_MELDUNG_CRON:-0 7 * * *}
|
||||||
# Absichtlich KEIN Master-Key: eine Luecke im Webprozess allein entschluesselt keine
|
# Absichtlich KEIN Master-Key: eine Luecke im Webprozess allein entschluesselt keine
|
||||||
# Private Keys. Nach aussen braucht er nur den OIDC-Anbieter.
|
# Private Keys. Nach aussen braucht er nur den OIDC-Anbieter.
|
||||||
ports:
|
ports:
|
||||||
@@ -94,9 +103,19 @@ services:
|
|||||||
SSHKV_APP_SECRET: ${SSHKV_APP_SECRET}
|
SSHKV_APP_SECRET: ${SSHKV_APP_SECRET}
|
||||||
SSHKV_MASTER_KEY_FILE: /run/secrets/master_key
|
SSHKV_MASTER_KEY_FILE: /run/secrets/master_key
|
||||||
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
|
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
|
||||||
|
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
|
||||||
|
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
|
||||||
|
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
|
||||||
|
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
|
||||||
|
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
|
||||||
|
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
|
||||||
|
# Benachrichtigungen (#028): taegliche Zusammenfassung; Links in den Nachrichten zeigen
|
||||||
|
# auf SSHKV_APP_URL. Den Weg zu Mailserver und Webhook-Ziel hat der Worker ueber "aussen".
|
||||||
|
SSHKV_MELDUNG_CRON: ${SSHKV_MELDUNG_CRON:-0 7 * * *}
|
||||||
|
SSHKV_APP_URL: ${SSHKV_APP_URL}
|
||||||
secrets:
|
secrets:
|
||||||
- master_key
|
- master_key
|
||||||
# Der Worker braucht die Datenbank und den Weg zu den Hosts.
|
# Der Worker braucht die Datenbank und den Weg zu den Hosts (und zu Mailserver/Webhook).
|
||||||
networks: [datenbank, aussen]
|
networks: [datenbank, aussen]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"]
|
test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"]
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# SSHKeyVault – Handbuch
|
||||||
|
|
||||||
|
SSHKeyVault (`sshkv`) verwaltet SSH-Schlüssel zentral: Schlüsselpaare erzeugen, Public Keys in
|
||||||
|
`authorized_keys` verteilen, Private Keys gezielt auf andere Hosts legen, Ist und Soll abgleichen –
|
||||||
|
alles protokolliert. Installation und Betrieb: [../betrieb/installation.md](../betrieb/installation.md),
|
||||||
|
Sicherung: [../betrieb/sicherung.md](../betrieb/sicherung.md), Sicherheit:
|
||||||
|
[../betrieb/sicherheit.md](../betrieb/sicherheit.md).
|
||||||
|
|
||||||
|
**Grundsatz:** Auf einem Host ändert sich nur etwas, nachdem Sie einen **Dry-Run** gesehen und
|
||||||
|
„Änderung anwenden“ geklickt haben. Alles, was sshkv in `authorized_keys`, `~/.ssh/config` und
|
||||||
|
`known_hosts` schreibt, steht in einem verwalteten Block:
|
||||||
|
|
||||||
|
```
|
||||||
|
# BEGIN SSHKV MANAGED – nicht manuell bearbeiten
|
||||||
|
…
|
||||||
|
# END SSHKV MANAGED
|
||||||
|
```
|
||||||
|
|
||||||
|
Zeilen außerhalb dieses Blocks bleiben unverändert – außer Sie übernehmen einen Schlüssel ausdrücklich
|
||||||
|
in die Verwaltung.
|
||||||
|
|
||||||
|
1. [Anmelden](#1-anmelden)
|
||||||
|
2. [Übersicht](#2-übersicht)
|
||||||
|
3. [Einen Host aufnehmen](#3-einen-host-aufnehmen)
|
||||||
|
4. [Kern-Anwendungsfall: Host A meldet sich bei Host B an](#4-kern-anwendungsfall-host-a-meldet-sich-bei-host-b-an)
|
||||||
|
5. [Deployment-Zugang für Claude Code mit dem Assistenten](#5-deployment-zugang-für-claude-code-mit-dem-assistenten)
|
||||||
|
6. [Abgleich, Drift und Übernahme](#6-abgleich-drift-und-übernahme)
|
||||||
|
7. [Rotation und Widerruf](#7-rotation-und-widerruf)
|
||||||
|
8. [Personen, Self-Service und Zuweisungsmatrix](#8-personen-self-service-und-zuweisungsmatrix)
|
||||||
|
9. [Vorlagen](#9-vorlagen)
|
||||||
|
10. [Private Key anzeigen oder löschen](#10-private-key-anzeigen-oder-löschen)
|
||||||
|
11. [Audit-Log](#11-audit-log)
|
||||||
|
12. [Einstellungen und Benachrichtigungen](#12-einstellungen-und-benachrichtigungen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Anmelden
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **Mit authentik anmelden** – der Normalfall. Ihre Rolle (Leser, Operator, Admin) kommt aus den
|
||||||
|
authentik-Gruppen und wird bei jeder Anmeldung neu bestimmt.
|
||||||
|
- **Notfallzugang** – nur, wenn authentik ausfällt. Anmeldename, Passwort und ein Code aus einer
|
||||||
|
Authenticator-App. Bei der ersten Anmeldung richtet der Notfall-Admin den zweiten Faktor ein
|
||||||
|
(QR-Code scannen, Code bestätigen).
|
||||||
|
|
||||||
|
Kritische Aktionen (Private Key anzeigen, Benutzer mit sudo anlegen, Massenwiderruf, Host-Key
|
||||||
|
ersetzen) verlangen eine **erneute Bestätigung**; sie gilt fünf Minuten.
|
||||||
|
|
||||||
|
## 2. Übersicht
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Kennzahlen, Hosts mit Abweichung, bald ablaufende Schlüssel und die letzten Aktionen. Von hier aus:
|
||||||
|
**Jetzt abgleichen** (alle Hosts), **Host-zu-Host-Assistent**, **Neuer Schlüssel**.
|
||||||
|
|
||||||
|
## 3. Einen Host aufnehmen
|
||||||
|
|
||||||
|
**Hosts → + Host anlegen:** Name (z. B. `srv-pve01`), Adresse, Port, Gruppen, Tags. Bei
|
||||||
|
**Anmeldung** ist „Dienstbenutzer `sshkv` mit sudo“ empfohlen – im auth.log des Hosts ist dann
|
||||||
|
eindeutig zu sehen, was von sshkv kam, und root-Login kann aus bleiben.
|
||||||
|
|
||||||
|
**Host-Key bestätigen.** „Host-Key abrufen“ holt den Fingerprint, ohne sich anzumelden. Vergleichen
|
||||||
|
Sie ihn direkt am Host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Erst nach „Fingerprint stimmt – vertrauen“ verbindet sich sshkv. Ändert sich der Host-Key später,
|
||||||
|
bricht jede Verbindung ab und die Hostseite warnt deutlich – annehmen nur, wenn der Host wirklich neu
|
||||||
|
installiert wurde.
|
||||||
|
|
||||||
|
**Bootstrap.** Zwei Wege, den Deployment-Key von sshkv auf den Host zu bringen:
|
||||||
|
|
||||||
|
- **Einmalig per Passwort** (root oder ein Benutzer mit sudo). Das Passwort wird nicht gespeichert.
|
||||||
|
- **Per Befehl:** den angezeigten Befehl auf dem Host einfügen, danach „Einrichtung prüfen“.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Danach zeigt die Hostseite den **sshd-Befund**. Steht dort „root darf sich per Passwort anmelden“,
|
||||||
|
empfiehlt sich `PermitRootLogin prohibit-password` in `/etc/ssh/sshd_config` – sshkv ändert das nicht
|
||||||
|
von sich aus.
|
||||||
|
|
||||||
|
## 4. Kern-Anwendungsfall: Host A meldet sich bei Host B an
|
||||||
|
|
||||||
|
Beispiel: Der Backup-Server `srv-backup01` soll sich als `root` per SSH auf `srv-pve01` anmelden.
|
||||||
|
Beide Hosts sind aufgenommen (Kapitel 3).
|
||||||
|
|
||||||
|
**1. Schlüssel erzeugen.** *Schlüssel → + Schlüssel anlegen → Erzeugen*: Zweck „Backup srv-backup01 →
|
||||||
|
srv-pve01“, Typ ed25519, optional Ablaufdatum. Keine Passphrase – ein Cronjob kann sie nicht eingeben.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**2a. Private Key auf A.** In der Karte *Private Key ausgerollt auf* → **+ Ausrollen**: Quell-Host
|
||||||
|
`srv-backup01`, Benutzer `root`, Pfad `~/.ssh/id_ed25519_…`. Mit **Eintrag in ~/.ssh/config** und
|
||||||
|
Ziel-Host `srv-pve01` legt sshkv einen Alias an und trägt den (bereits bestätigten) Host-Key von B in
|
||||||
|
`known_hosts` von A ein. *HostName* ist die Adresse, unter der **A** den Host B erreicht.
|
||||||
|
|
||||||
|
**2b. Public Key auf B.** In der Karte *Public Key ausgerollt auf* → **+ Ausrollen**: Ziel
|
||||||
|
`srv-pve01`, Benutzer `root`, optional Einschränkungen wie `from="10.0.0.12",no-pty`.
|
||||||
|
|
||||||
|
2a und 2b sind unabhängig und jederzeit nachholbar.
|
||||||
|
|
||||||
|
**3. Dry-Run und anwenden.** *Dry-Run starten* zeigt je Host, was sich ändert – beim Private Key nur
|
||||||
|
„wird angelegt“, nie den Inhalt.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
„Änderung anwenden“ schreibt genau das, was Sie gesehen haben (mit Sicherung der Dateien). Hat sich
|
||||||
|
in der Zwischenzeit etwas auf dem Host geändert, bricht sshkv ab – dann den Dry-Run neu starten.
|
||||||
|
|
||||||
|
**4. Verbindung testen.** In der Private-Karte „Verbindung testen“ ruft auf A
|
||||||
|
`ssh -o BatchMode=yes <alias> true` auf – genau das, was später der Cronjob braucht.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 5. Deployment-Zugang für Claude Code mit dem Assistenten
|
||||||
|
|
||||||
|
Szenario: Claude Code auf `srv-rz-claude-linux` soll eine Anwendung per SSH auf `srv-app01`
|
||||||
|
ausrollen. **Übersicht → Host-zu-Host-Assistent** (nur Admins):
|
||||||
|
|
||||||
|
1. **Vorlage „Deployment-Zugang“** wählen, Zweck „Claude Code Deployment → srv-app01“. Die Vorlage
|
||||||
|
setzt Benutzer `deploy`, 90 Tage Laufzeit, `from="<IP von A>"` und den Alias `deploy-<Ziel>`.
|
||||||
|
2. **Quelle:** `srv-rz-claude-linux`, Benutzer, unter dem Claude Code läuft.
|
||||||
|
3. **Ziel:** `srv-app01`, Benutzer `deploy` – „Benutzer anlegen“ ist vorgewählt; sudo nach Bedarf
|
||||||
|
(keine, bestimmte Befehle mit Vorschau der sudoers-Datei, oder voll). Fingerprint von B bestätigen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
4. **Prüfen** und „Vorbereiten“: sshkv erzeugt den Schlüssel, legt `deploy` an (frische Bestätigung
|
||||||
|
nötig – sie öffnet sich in einem neuen Tab, Ihre Eingaben bleiben stehen) und legt die
|
||||||
|
Zuweisungen an.
|
||||||
|
5. **Ausrollen:** Dry-Run, anwenden, „Verbindung von A nach B testen“.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Claude Code kann danach einfach `ssh deploy-srv-app01` benutzen. Wählen Sie `root` als Ziel, warnt
|
||||||
|
der Assistent: Ein KI-Agent hätte dann uneingeschränkten Zugriff.
|
||||||
|
|
||||||
|
**Nach Projektende** (oder Ablaufdatum): auf der Schlüsselseite **Widerrufen** – entfernt Public und
|
||||||
|
Private Key, config- und known_hosts-Eintrag und auf Wunsch den Benutzer `deploy` samt sudoers-Datei.
|
||||||
|
|
||||||
|
## 6. Abgleich, Drift und Übernahme
|
||||||
|
|
||||||
|
Nachts um 02:30 (einstellbar) und auf Knopfdruck vergleicht sshkv jeden Host mit dem Soll. Gefunden
|
||||||
|
werden fehlende Schlüssel, von Hand geänderte Blöcke, **unbekannte Schlüssel** außerhalb des Blocks,
|
||||||
|
falsche Rechte von `~/.ssh` und veränderte Private Keys. Der Abgleich ändert auf dem Host nichts.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Ein unbekannter Schlüssel lässt sich **übernehmen**: nur als bekannt aufnehmen (der Befund
|
||||||
|
verschwindet) oder „in den verwalteten Block holen“ – dann wandert die Zeile beim nächsten Ausrollen
|
||||||
|
(mit Dry-Run) in den Block.
|
||||||
|
|
||||||
|
## 7. Rotation und Widerruf
|
||||||
|
|
||||||
|
**Rotieren** (Schlüsselseite): Ein neuer Schlüssel kommt an alle Stellen des alten, der Alias in
|
||||||
|
`~/.ssh/config` zeigt auf die neue Datei, die Verbindungen werden getestet – **erst dann** wird der
|
||||||
|
alte entfernt. Die Rotation lässt sich jederzeit fortsetzen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Widerrufen** (Schlüsselseite, Gefahr-Knopf): Der Dialog zeigt, was entfernt wird; danach Dry-Run
|
||||||
|
und anwenden. Ist ein Host nicht erreichbar, bleibt der Schlüssel als „Widerruf unvollständig“
|
||||||
|
markiert, bis auch dort alles entfernt ist. **Massenwiderruf:** in der Schlüsselliste mehrere
|
||||||
|
auswählen (nur Admin).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Deployment-Key rotieren** (Einstellungen → Instanz, nur Admin): fünf Schritte – neuen Key
|
||||||
|
**erzeugen**, per Dry-Run auf alle Hosts **verteilen** (er steht dann neben dem alten), die Anmeldung
|
||||||
|
mit dem neuen Key **prüfen**, **umstellen** und zuletzt den alten per Dry-Run **aufräumen**. Scheitert
|
||||||
|
die Prüfung auf einem Host, lässt er sich bewusst übergehen; sshkv erreicht ihn danach nur noch nach
|
||||||
|
einem neuen Bootstrap.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 8. Personen, Self-Service und Zuweisungsmatrix
|
||||||
|
|
||||||
|
- **Personen:** Admins, Mitarbeiter, Dienstleister mit ihren Schlüsseln. Private Keys von Personen
|
||||||
|
speichert sshkv nie.
|
||||||
|
- **Meine Schlüssel:** Jede angemeldete Person kann eigene Public Keys hinterlegen. Wirksam werden
|
||||||
|
sie erst, wenn ein Operator oder Admin sie einem Host zuweist.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **Zuweisungen:** Matrix Schlüssel × Host mit Filtern; ein Klick auf eine Zelle zeigt die
|
||||||
|
Zuweisungen und erlaubt neue. Export als CSV.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 9. Vorlagen
|
||||||
|
|
||||||
|
Vorlagen legen Schlüsseltyp, Laufzeit, Zielbenutzer (samt Anlegen und sudo), `authorized_keys`-
|
||||||
|
Optionen und das Alias-Muster vor. Platzhalter: `{{quellHost}}`, `{{quellHostIp}}`, `{{zielHost}}`,
|
||||||
|
`{{zielHostIp}}`. Eingebaut: „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 10. Private Key anzeigen oder löschen
|
||||||
|
|
||||||
|
Nur Admins, nur nach erneuter Bestätigung: „anzeigen (Re-Auth)“ in der Info-Leiste eines Schlüssels.
|
||||||
|
Der Key wird genau einmal ausgeliefert und nirgends zwischengespeichert; jede Anzeige steht im
|
||||||
|
Protokoll. **Private Key löschen** entfernt ihn endgültig aus sshkv – auf den Hosts bleibt er; neu
|
||||||
|
ausrollen geht danach nicht mehr.
|
||||||
|
|
||||||
|
## 11. Audit-Log
|
||||||
|
|
||||||
|
Jede Aktion steht im **Audit-Log**: Zeitpunkt, Benutzer, Host, Zielbenutzer, Fingerprint, das Diff
|
||||||
|
der geänderten Datei und das Ergebnis – nie ein Private Key oder Passwort. Einträge lassen sich
|
||||||
|
weder ändern noch löschen; jeder Eintrag ist per Hash mit seinem Vorgänger verkettet.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **Filter** nach Zeitraum, Aktionsgruppe, Benutzer, Host, Ergebnis und Freitext; eine Zeile
|
||||||
|
aufklappen zeigt Diff, Details und Hashes.
|
||||||
|
- **Kette prüfen** rechnet alle Hashes nach. Wurde eine Zeile an sshkv vorbei in der Datenbank
|
||||||
|
verändert, zeigt die Seite die erste betroffene Zeile.
|
||||||
|
- **CSV-Export** mit denselben Filtern (der Export selbst wird protokolliert).
|
||||||
|
- **Syslog:** Mit `SSHKV_SYSLOG_HOST` geht jeder Eintrag zusätzlich an einen zentralen Syslog-Server
|
||||||
|
(z. B. für Zabbix) – eine Kopie außerhalb von sshkv.
|
||||||
|
|
||||||
|
## 12. Einstellungen und Benachrichtigungen
|
||||||
|
|
||||||
|
Unter **Einstellungen** (ändern nur Admins) richten Sie Benachrichtigungen ein:
|
||||||
|
|
||||||
|
- **E-Mail** über SMTP – für Microsoft 365 vorbelegt (`smtp.office365.com:587`, STARTTLS).
|
||||||
|
- **Webhook** (nur `https://`, außer ausdrücklich für interne Ziele erlaubt) mit optionaler Signatur
|
||||||
|
`X-Sshkv-Signatur: sha256=…` über den Inhalt.
|
||||||
|
- **Anlässe** einzeln schaltbar: neue Abweichungen, Schlüssel, die in 30/14/7 Tagen ablaufen,
|
||||||
|
abgelaufene, aber noch ausgerollte Schlüssel, nicht erreichbare Hosts – als **tägliche
|
||||||
|
Zusammenfassung** um 07:00 (`SSHKV_MELDUNG_CRON`) – und **geänderte Host-Keys sofort**.
|
||||||
|
- **Testnachricht senden** je Kanal; die letzten Zustellungen stehen darunter. Jede Meldung geht nur
|
||||||
|
einmal hinaus; fehlgeschlagene werden einige Male wiederholt.
|
||||||
|
|
||||||
|
Passwörter und Geheimnisse werden verschlüsselt gespeichert und nie wieder angezeigt („gesetzt“).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 481 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -0,0 +1,113 @@
|
|||||||
|
# Installation und Betrieb
|
||||||
|
|
||||||
|
SSHKeyVault läuft als Docker-Compose-Stapel aus drei Diensten plus einem einmaligen Migrationslauf:
|
||||||
|
|
||||||
|
| Dienst | Aufgabe | Netz | Geheimnisse |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `db` | PostgreSQL 17 | nur intern (`internal: true`) | Datenbankpasswort |
|
||||||
|
| `migrate` | Schema einspielen, Notfall-Admin und Vorlagen anlegen (läuft bei jedem Start einmal) | intern | Notfall-Passwort beim ersten Start |
|
||||||
|
| `web` | Weboberfläche (Port 3400) | intern + nach außen zum OIDC-Anbieter | `SSHKV_APP_SECRET`, OIDC-Client-Secret – **kein Master-Key** |
|
||||||
|
| `worker` | SSH zu den Hosts, Ver-/Entschlüsselung, Zeitpläne | intern + zu den Hosts | Master-Key (Docker-Secret), `SSHKV_APP_SECRET` |
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Linux-Host mit Docker und Docker Compose v2, **im internen Netz bzw. nur per VPN erreichbar**.
|
||||||
|
- Der Worker muss die zu verwaltenden Hosts per SSH erreichen (Port 22 oder den jeweiligen Port).
|
||||||
|
- authentik (oder ein anderer OIDC-Anbieter) für die Anmeldung.
|
||||||
|
- Ein Reverse Proxy mit TLS vor der Weboberfläche.
|
||||||
|
|
||||||
|
## Schritt für Schritt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/sshkv && cd /opt/sshkv
|
||||||
|
git clone <repo> . # oder Release entpacken
|
||||||
|
cp .env.example .env && chmod 600 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
In `.env` eintragen (Kommentare in der Datei erklären jede Variable):
|
||||||
|
|
||||||
|
- `POSTGRES_PASSWORD` – `openssl rand -hex 24`
|
||||||
|
- `SSHKV_APP_SECRET` – `openssl rand -base64 48`
|
||||||
|
- `SSHKV_APP_URL` – die Adresse, unter der Benutzer sshkv aufrufen, z. B. `https://sshkv.intern.example.de`
|
||||||
|
- `SSHKV_BREAKGLASS_PASSWORD` – mindestens 16 Zeichen; Notfall-Admin `notfall`
|
||||||
|
- `SSHKV_OIDC_*` – siehe unten
|
||||||
|
|
||||||
|
Master-Key erzeugen und nur für den Worker lesbar machen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p geheim
|
||||||
|
sudo sh -c 'head -c 32 /dev/urandom > geheim/master.key'
|
||||||
|
sudo chown 1001:1001 geheim/master.key && sudo chmod 400 geheim/master.key
|
||||||
|
```
|
||||||
|
|
||||||
|
Starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f worker # „sshkv-worker … bereit“
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Start legt `migrate` das Schema, den Notfall-Admin und die eingebauten Vorlagen an; der
|
||||||
|
Worker erzeugt den **Deployment-Key** der Instanz. Danach sofort den Master-Key sichern
|
||||||
|
([sicherung.md](sicherung.md)).
|
||||||
|
|
||||||
|
## authentik einrichten
|
||||||
|
|
||||||
|
1. **Provider** „OAuth2/OpenID Provider“ anlegen: Client-Typ *confidential*, Redirect-URI
|
||||||
|
`https://sshkv.intern.example.de/api/auth/oidc/rueckruf`, Scopes `openid profile email` und ein
|
||||||
|
Scope-Mapping, das die Gruppen als Claim `groups` liefert (in authentik Standard).
|
||||||
|
2. **Application** „SSHKeyVault“ mit diesem Provider anlegen.
|
||||||
|
3. **Gruppen** `sshkv-admins`, `sshkv-operators`, `sshkv-readers` anlegen und Benutzer zuordnen
|
||||||
|
(Namen änderbar über `SSHKV_OIDC_GROUP_*`). Wer in keiner Gruppe ist, kommt nicht hinein.
|
||||||
|
4. In `.env`: `SSHKV_OIDC_ISSUER=https://authentik.example.de/application/o/sshkv/`,
|
||||||
|
`SSHKV_OIDC_CLIENT_ID`, `SSHKV_OIDC_CLIENT_SECRET`, `SSHKV_OIDC_NAME=authentik`.
|
||||||
|
|
||||||
|
Erneute Bestätigung vor kritischen Aktionen schickt `prompt=login` und `max_age=0`; sshkv prüft
|
||||||
|
`auth_time` im ID-Token. authentik liefert `auth_time` standardmäßig.
|
||||||
|
|
||||||
|
| Rolle | darf |
|
||||||
|
|---|---|
|
||||||
|
| Leser | alles ansehen, eigene Public Keys hinterlegen |
|
||||||
|
| Operator | zusätzlich Hosts verwalten, Host-Keys bestätigen, Schlüssel erzeugen, Public Keys ausrollen, abgleichen |
|
||||||
|
| Admin | zusätzlich Private Keys ausrollen/anzeigen/löschen, Zielkonten mit sudo, Massenwiderruf, Einstellungen, Vorlagen |
|
||||||
|
|
||||||
|
## Reverse Proxy (Beispiel Caddy)
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
sshkv.intern.example.de {
|
||||||
|
tls internal # oder das interne Zertifikat
|
||||||
|
@extern not remote_ip 10.0.0.0/8 192.168.0.0/16
|
||||||
|
respond @extern 403
|
||||||
|
reverse_proxy 127.0.0.1:3400
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtig: `SSHKV_APP_URL` muss die Adresse mit `https://` sein – davon hängen Secure-Cookie und
|
||||||
|
OIDC-Rückruf ab.
|
||||||
|
|
||||||
|
## Hosts vorbereiten
|
||||||
|
|
||||||
|
- **Debian/Ubuntu** mit `openssh-server`, `sudo`, `runuser` (util-linux), `base64`, `sha256sum`,
|
||||||
|
`visudo` (bei Dienstbenutzer) – auf Standardinstallationen vorhanden.
|
||||||
|
- Für den Bootstrap per Passwort einmalig ein Konto mit Passwort-Login (root oder ein Benutzer mit
|
||||||
|
sudo). Danach kann Passwort-Login aus; sshkv zeigt den sshd-Befund an.
|
||||||
|
|
||||||
|
## Aktualisieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/sshkv
|
||||||
|
sudo bash deploy/sichern.sh
|
||||||
|
git pull
|
||||||
|
docker compose up -d --build # migrate spielt neue Migrationen ein
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variablen (Auswahl)
|
||||||
|
|
||||||
|
| Variable | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `SSHKV_SYNC_CRON` | Zeitplan des Abgleichs (Standard `30 2 * * *`, Europe/Berlin) |
|
||||||
|
| `SSHKV_MELDUNG_CRON` | tägliche Zusammenfassung der Benachrichtigungen (Standard `0 7 * * *`); geänderte Host-Keys gehen alle 5 Minuten sofort hinaus |
|
||||||
|
| `SSHKV_SYSLOG_HOST`/`_PORT`/`_PROTOKOLL` | Kopie des Protokolls an einen Syslog-Server (RFC 5424, UDP oder TCP) |
|
||||||
|
| `SSHKV_SYSLOG_FACILITY` / `_APPNAME` | Facility (Standard `authpriv`) und App-Name (Standard `sshkv`) |
|
||||||
|
| `SSHKV_WORKER_USER` | Benutzer des Worker-Containers, falls die Master-Key-Datei nicht UID 1001 gehört |
|
||||||
|
| `SSHKV_BIND` / `SSHKV_PORT` | Bindeadresse/Port der Weboberfläche (Standard `127.0.0.1:3400`) |
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Sicherheit und Bedrohungsanalyse
|
||||||
|
|
||||||
|
SSHKeyVault (`sshkv`) verwaltet die SSH-Zugänge zu Servern – und hat dafür selbst
|
||||||
|
**root-gleichen Zugriff auf alle verwalteten Hosts**. Wer sshkv übernimmt, übernimmt die
|
||||||
|
Infrastruktur. Diese Seite beschreibt, wie sshkv aufgebaut ist, um das zu erschweren, was
|
||||||
|
trotzdem passieren kann und was im Ernstfall zu tun ist.
|
||||||
|
|
||||||
|
## Was sshkv besitzt
|
||||||
|
|
||||||
|
| Wert | Wo | Schutz |
|
||||||
|
|---|---|---|
|
||||||
|
| **Deployment-Key** (Private Key) | Datenbank, verschlüsselt | Envelope Encryption mit Master-Key; nur der Worker entschlüsselt |
|
||||||
|
| **Private Keys** von Service-Schlüsseln | Datenbank, verschlüsselt | wie oben; Anzeige nur für Admins mit frischer Bestätigung, protokolliert |
|
||||||
|
| **Master-Key** | Datei auf dem Docker-Host, nur im Worker eingebunden | Dateirechte 0400, nicht in der Datenbank, nicht im Image, nicht im Repo |
|
||||||
|
| `SSHKV_APP_SECRET` | `.env` | signiert Sitzungen, verschlüsselt TOTP-Geheimnis, SMTP-Passwort, Webhook-Geheimnis und Job-Transporte |
|
||||||
|
| Host-Keys der Hosts | Datenbank | öffentlich; schützen vor Man-in-the-Middle (strikte Prüfung) |
|
||||||
|
| Protokoll | Datenbank | append-only per Trigger, Hash-Kette, optional Syslog |
|
||||||
|
|
||||||
|
**Personen-Keys** speichert sshkv nur als Public Key – ein Datenbankregel verhindert, dass dort je ein
|
||||||
|
Private Key steht. Bootstrap-Passwörter werden nie gespeichert.
|
||||||
|
|
||||||
|
## Aufbau, der Angriffe erschwert
|
||||||
|
|
||||||
|
- **Web und Worker getrennt.** Der Webprozess kennt den Master-Key nicht und spricht kein SSH. Eine
|
||||||
|
Lücke in der Weboberfläche allein entschlüsselt keine Private Keys; ein Test hält strukturell fest,
|
||||||
|
dass der Webprozess nichts aus `src/worker` importiert und nur eine einzige Datei die
|
||||||
|
verschlüsselten Felder liest.
|
||||||
|
- **Envelope Encryption** (AES-256-GCM): je Private Key ein eigener Datenschlüssel, gebunden an ID und
|
||||||
|
Fingerprint des Schlüssels. Ein Datenbank-Dump allein gibt keinen Schlüssel preis (Test).
|
||||||
|
- **Strikte Host-Key-Prüfung** bei jeder Verbindung. Ein geänderter Host-Key bricht die Verbindung vor
|
||||||
|
dem Senden von Zugangsdaten ab; Ersetzen nur mit Häkchen und frischer Bestätigung.
|
||||||
|
- **Dateien auf den Hosts** schreibt sshkv als jeweiliger Zielbenutzer (`runuser`): Ein Symlink im
|
||||||
|
Home eines Benutzers kann root nicht dazu bringen, `/etc/shadow` zu lesen oder zu überschreiben
|
||||||
|
(Integrationstest).
|
||||||
|
- **Dry-Run mit optimistischer Sperre:** Ausgerollt wird nur, was ein Mensch gesehen hat. Hat sich die
|
||||||
|
Datei oder die Zuweisung seitdem geändert, bricht der Vorgang ab.
|
||||||
|
- **Rollen:** Private Keys anzeigen, ausrollen und löschen, Deployment-Key, Zielkonten mit sudo und
|
||||||
|
Massenwiderruf nur für Admins; kritische Aktionen zusätzlich mit erneuter Anmeldung (5 Minuten).
|
||||||
|
Ohne passende authentik-Gruppe gibt es keinen Zugang, auch keinen lesenden.
|
||||||
|
- **Notfall-Admin** nur mit Passwort **und** TOTP; Rate-Limit gegen Raten.
|
||||||
|
- **Weboberfläche:** Content-Security-Policy mit Nonce, `X-Frame-Options: DENY`, `Referrer-Policy:
|
||||||
|
no-referrer`, HttpOnly-/SameSite-Cookies, Secure hinter HTTPS; Server Actions mit Origin-Prüfung.
|
||||||
|
|
||||||
|
## Was passiert, wenn sshkv kompromittiert wird?
|
||||||
|
|
||||||
|
| Angreifer hat … | Folge | Gegenmaßnahme |
|
||||||
|
|---|---|---|
|
||||||
|
| nur ein **Datenbank-Backup** | Hosts, Zuweisungen, Public Keys, Protokoll sichtbar; **keine** Private Keys | Backup trotzdem vertraulich behandeln (verrät die Struktur der Infrastruktur) |
|
||||||
|
| **Datenbank + Master-Key** | alle Private Keys inkl. Deployment-Key → root auf allen Hosts | Master-Key getrennt aufbewahren; Notfallplan unten |
|
||||||
|
| die **Weboberfläche** als Admin (gestohlene Sitzung) | kann Schlüssel zuweisen und ausrollen; Private Keys nur nach erneuter Anmeldung | kurze Sitzungen (8 h), Re-Auth, Protokoll + Syslog zeigen jede Aktion |
|
||||||
|
| den **Webprozess** (Codeausführung) | Datenbankzugriff, aber kein Master-Key | Worker getrennt; Master-Key nur im Worker-Container |
|
||||||
|
| den **Worker** oder Docker-Host | alles | Host härten, nur internes Netz, Updates, Zugriff auf den Docker-Host streng begrenzen |
|
||||||
|
| einen **verwalteten Host** | kann sshkv nicht angreifen; sieht nur den Public Key des Deployment-Keys | — |
|
||||||
|
|
||||||
|
### Notfallplan „sshkv kompromittiert“
|
||||||
|
|
||||||
|
1. **Sofort:** Netzwerkzugang zu sshkv sperren (Firewall/VPN), Container anhalten
|
||||||
|
(`docker compose stop`). Die Hosts bleiben erreichbar – sshkv ist kein Teil des Logins.
|
||||||
|
2. **Deployment-Key auf allen Hosts entfernen:** Auf jedem Host in `~sshkv/.ssh/authorized_keys`
|
||||||
|
(bzw. `/root/.ssh/authorized_keys` bei Anmeldeart root) die Zeile mit `sshkv-deployment@…` löschen
|
||||||
|
und `/etc/sudoers.d/sshkv` entfernen. Mit einem Konfigurationsmanagement oder einer Schleife über
|
||||||
|
die Hostliste (CSV-Export der Zuweisungen bzw. Hostliste).
|
||||||
|
3. **Ausgerollte Service-Keys tauschen:** Jeder Private Key, den sshkv kannte, gilt als bekannt.
|
||||||
|
Nach dem Wiederaufbau alle Service-Schlüssel rotieren (geführte Rotation) und alte widerrufen.
|
||||||
|
4. **Neu aufsetzen:** neuer Master-Key, neues `SSHKV_APP_SECRET`, frische Datenbank oder geprüftes
|
||||||
|
Backup; der Worker erzeugt einen neuen Deployment-Key; Hosts per Bootstrap neu aufnehmen.
|
||||||
|
5. **Protokoll auswerten:** Syslog-Kopie (unveränderlich außerhalb von sshkv) mit dem Protokoll
|
||||||
|
vergleichen; `protokoll_kette_pruefen()` zeigt manipulierte Zeilen.
|
||||||
|
|
||||||
|
> **Rotation des Deployment-Keys** (Einstellungen → Instanz → „Deployment-Key rotieren“, nur Admin mit
|
||||||
|
> frischer Bestätigung): regelmäßig, oder wenn nur der Key abgeflossen sein kann – etwa ein verlorenes
|
||||||
|
> Backup samt Master-Key. Der neue Key kommt erst neben den alten, wird auf jedem Host geprüft und löst
|
||||||
|
> den alten dann ab; zuletzt wird der alte aus allen Blöcken entfernt und sein Private Key verworfen.
|
||||||
|
> Ist sshkv **selbst** kompromittiert, hilft das nicht – der Instanz ist dann nicht mehr zu trauen,
|
||||||
|
> Schritt 2 bleibt Handarbeit.
|
||||||
|
|
||||||
|
## Betriebsregeln
|
||||||
|
|
||||||
|
- **Nur intern betreiben.** Die Weboberfläche gehört ins interne Netz oder hinter ein VPN, nie ins
|
||||||
|
Internet. Compose bindet standardmäßig nur an `127.0.0.1`; ein Reverse Proxy mit TLS steht davor
|
||||||
|
(siehe [installation.md](installation.md)).
|
||||||
|
- **Docker-Host wie einen Bastion-Host behandeln:** wenige Admins, SSH nur mit Schlüssel, Updates,
|
||||||
|
keine anderen Dienste.
|
||||||
|
- **Master-Key** getrennt sichern, Rechte 0400, nur für den Worker-Benutzer lesbar.
|
||||||
|
- **Syslog** einschalten (`SSHKV_SYSLOG_*`), damit eine Kopie des Protokolls außerhalb von sshkv liegt.
|
||||||
|
- **Hosts:** `PermitRootLogin prohibit-password` (sshkv zeigt das als Hinweis an), Anmeldeart
|
||||||
|
„Dienstbenutzer“ bevorzugen – dann kann root-Login ganz aus bleiben.
|
||||||
|
- **Benachrichtigungen** für Drift und geänderte Host-Keys aktivieren, damit Abweichungen auffallen.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Sicherung und Wiederherstellung
|
||||||
|
|
||||||
|
Eine Sicherung von SSHKeyVault besteht aus **zwei Teilen, die getrennt aufbewahrt werden**:
|
||||||
|
|
||||||
|
| Teil | Inhalt | Wie oft | Wohin |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Datenbank** | Hosts, Schlüssel (Private Keys nur verschlüsselt), Zuweisungen, Protokoll, Einstellungen | täglich | normales Backup-Ziel (`/var/backups/sshkv`, von dort auf das Sicherungssystem) |
|
||||||
|
| **Master-Key** | 32 Byte, entschlüsselt alle Private Keys | einmalig nach der Installation und nach jeder Master-Key-Rotation | **getrennt:** Passwortmanager, Tresor, verschlüsselter Datenträger |
|
||||||
|
| `.env` | `SSHKV_APP_SECRET`, OIDC-Zugang, Datenbankpasswort | nach jeder Änderung | wie der Master-Key |
|
||||||
|
|
||||||
|
> **Ohne Master-Key ist jedes Datenbank-Backup wertlos** – die Private Keys lassen sich nicht mehr
|
||||||
|
> entschlüsseln. **Mit Master-Key neben dem Backup** gibt ein gestohlenes Backup alle Private Keys
|
||||||
|
> preis. Deshalb niemals beides am selben Ort.
|
||||||
|
>
|
||||||
|
> Ohne das richtige `SSHKV_APP_SECRET` gehen TOTP des Notfall-Admins, SMTP-Passwort und
|
||||||
|
> Webhook-Geheimnis verloren (neu einrichten genügt); Private Keys hängen nur am Master-Key.
|
||||||
|
|
||||||
|
## Sichern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/sshkv # Verzeichnis mit docker-compose.yml und .env
|
||||||
|
sudo bash deploy/sichern.sh # nach /var/backups/sshkv
|
||||||
|
sudo bash deploy/sichern.sh /mnt/sicherung/sshkv
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript
|
||||||
|
|
||||||
|
- schreibt `datenbank-<Stand>.sql.gz` (`pg_dump --clean --if-exists`, nur das Schema `public` – die
|
||||||
|
Warteschlange legt der Worker selbst neu an),
|
||||||
|
- legt `master-key-pruefwert-<Stand>.txt` daneben: den SHA-256 der Master-Key-Datei. Er verrät nichts
|
||||||
|
über den Schlüssel, zeigt beim Zurückspielen aber, ob der richtige bereitliegt,
|
||||||
|
- behält die letzten 14 Sicherungen (`SSHKV_SICHERUNGEN_BEHALTEN`),
|
||||||
|
- sichert den Master-Key **nicht** mit.
|
||||||
|
|
||||||
|
Als täglicher Cronjob auf dem Docker-Host:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
15 3 * * * root cd /opt/sshkv && bash deploy/sichern.sh >/var/log/sshkv-sicherung.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Master-Key sichern (einmalig)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo base64 /opt/sshkv/geheim/master.key # Ausgabe in den Passwortmanager, Eintrag „sshkv Master-Key“
|
||||||
|
sudo sha256sum /opt/sshkv/geheim/master.key # Prüfwert dazuschreiben
|
||||||
|
```
|
||||||
|
|
||||||
|
Zurück in eine Datei: `echo '<base64>' | base64 -d > geheim/master.key && chmod 400 geheim/master.key`
|
||||||
|
und den Eigentümer setzen (Container-Benutzer 1001 oder `SSHKV_WORKER_USER`).
|
||||||
|
|
||||||
|
## Wiederherstellen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/sshkv
|
||||||
|
sudo bash deploy/wiederherstellen.sh /var/backups/sshkv # jüngste Sicherung
|
||||||
|
sudo bash deploy/wiederherstellen.sh /var/backups/sshkv 20261012-163958
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript
|
||||||
|
|
||||||
|
1. prüft den Master-Key unter `SSHKV_MASTER_KEY_HOST_FILE` gegen den Prüfwert der Sicherung und
|
||||||
|
**bricht ab, wenn er nicht passt**,
|
||||||
|
2. fragt nach (`--ja` überspringt),
|
||||||
|
3. hält Weboberfläche und Worker an,
|
||||||
|
4. spielt die Datenbank **in einer Transaktion** zurück – scheitert etwas, bleibt der alte Stand
|
||||||
|
vollständig erhalten,
|
||||||
|
5. setzt die Warteschlange zurück und bricht offene Vorgänge ab (ihre Jobs gehören nicht zur
|
||||||
|
Sicherung),
|
||||||
|
6. startet die Dienste und prüft, dass der Worker den Master-Key akzeptiert.
|
||||||
|
|
||||||
|
Zusätzlich prüft der **Worker bei jedem Start**, ob er den Deployment-Key mit dem eingebundenen
|
||||||
|
Master-Key entschlüsseln kann, und startet sonst nicht („Der Master-Key passt nicht zur Datenbank“).
|
||||||
|
|
||||||
|
### Geprobt am 12.10.2026
|
||||||
|
|
||||||
|
Auf einem Wegwerf-Stapel (gleiches `docker-compose.yml`):
|
||||||
|
|
||||||
|
1. Markierung angelegt (Host `srv-probe`, Protokolleintrag), gesichert.
|
||||||
|
2. Markierung gelöscht, weiterer Protokolleintrag geschrieben.
|
||||||
|
3. Zurückgespielt → Host wieder da, Protokoll wieder auf dem gesicherten Stand, Hash-Kette
|
||||||
|
unversehrt (`protokoll_kette_pruefen()`), Weboberfläche gesund, Worker bereit.
|
||||||
|
4. Mit **falschem Master-Key** zurückgespielt → Abbruch vor jeder Änderung.
|
||||||
|
|
||||||
|
Dabei fielen zwei Fehler auf, die jetzt behoben sind: Das Schema der Warteschlange ließ sich mit
|
||||||
|
`--clean` nicht zurückspielen, und ein Abbruch ohne Transaktion hatte es halb gelöscht.
|
||||||
|
|
||||||
|
## Nach einer Wiederherstellung
|
||||||
|
|
||||||
|
- **„Jetzt abgleichen“** auf der Übersicht: Der Stand der Hosts kann neuer sein als die Sicherung.
|
||||||
|
- Zuweisungen, die nach der Sicherung ausgerollt wurden, kennt sshkv nicht mehr – der Abgleich zeigt
|
||||||
|
sie als unbekannte Schlüssel; übernehmen oder entfernen.
|
||||||
|
- Wurde nach der Sicherung ein Schlüssel widerrufen, steht er wieder als aktiv da: erneut widerrufen.
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
# #018 Zielbenutzer anlegen und sudoers
|
# #018 Zielbenutzer anlegen und sudoers
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** backend, sicherheit
|
**Labels:** backend, sicherheit
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 30 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Benutzer (z. B. `deploy`) anlegen, optional sudoers mit Vorschau und `visudo -c`.
|
Benutzer (z. B. `deploy`) anlegen, optional sudoers mit Vorschau und `visudo -c`.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Auswahl keine/bestimmte Befehle/voll
|
- [x] Auswahl keine/bestimmte Befehle/voll
|
||||||
- [ ] Entfernen beim Widerruf
|
- [x] Entfernen beim Widerruf
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Integrationstest
|
- [x] Integrationstest
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#016
|
#016
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **sudoers** entsteht als `/etc/sudoers.d/.sshkv.XXXX` (Punkt im Namen: sudo ignoriert die Datei), wird mit `visudo -cf` geprüft und erst dann nach `/etc/sudoers.d/sshkv-<benutzer>` verschoben. Kopfzeile kennzeichnet sshkv-Dateien; eine fremde Datei gleichen Namens wird nie überschrieben.
|
||||||
|
- **Befehle:** absoluter Pfad, keine Zeichen mit Sonderbedeutung in sudoers (`, : = \`, Wildcards) – die Liste soll wirken, wie sie aussieht.
|
||||||
|
- **Geschützte Konten** (root, sshkv, Systemkonten) verwaltet sshkv nie als Zielkonto.
|
||||||
|
- **Herkunft gemerkt:** `vonSshkvAngelegt` – nur dann löscht ein Entfernen den Benutzer samt Home, sonst nur die sudoers-Datei.
|
||||||
|
- Vorgänge `ZIELKONTO_ANLEGEN`/`ZIELKONTO_ENTFERNEN` (Migration `20261012110000`), Recht `zielkontenVerwalten` = Admin mit frischer Bestätigung. Oberfläche im Assistenten (#020) mit sudoers-Vorschau.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# #019 Vorlagen
|
# #019 Vorlagen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||||
@@ -8,12 +8,25 @@
|
|||||||
Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen.
|
Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Platzhalter `{{quellHostIp}}` usw.
|
- [x] Platzhalter `{{quellHostIp}}` usw.
|
||||||
|
- [x] Oberfläche: Liste `/vorlagen` (alle lesen), Anlegen/Bearbeiten/Archivieren nur Admin
|
||||||
|
- [x] Live-Vorschau der aufgelösten Optionen, des Alias und der sudoers-Datei
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Vorlage füllt Assistent vor
|
- [ ] Vorlage füllt Assistent vor (folgt mit dem Assistenten, #020)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#017, #018
|
#017, #018
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- Vorlagen pflegt nur, wer `einstellungenVerwalten` darf (Admin); lesen darf jede Rolle, weil
|
||||||
|
Vorlagen keine Geheimnisse enthalten und Operatoren sie im Assistenten sehen.
|
||||||
|
- Die Vorschau im Formular ruft dieselben reinen Funktionen wie der Server
|
||||||
|
(`platzhalterErsetzen`, `optionenPruefen`, `sudoersInhalt`) mit eigenen Beispielwerten auf;
|
||||||
|
Fehler werden dort nur angezeigt, verbindlich prüft der Server beim Speichern.
|
||||||
|
- Das Befehlsfeld wird bei anderem sudo-Modus nur ausgeblendet, nicht entfernt – so gehen
|
||||||
|
eingegebene Befehle beim Umschalten nicht verloren; der Service verwirft sie ohnehin.
|
||||||
|
- Die sudoers-Vorschau bricht nicht um: sudoers ist zeilenbasiert, ein optischer Umbruch sähe
|
||||||
|
wie eine zweite Regel aus.
|
||||||
|
- Eingebaute Vorlagen lassen sich bearbeiten, aber nicht archivieren; ihre Bearbeiten-Seite
|
||||||
|
zeigt deshalb keinen Archivieren-Knopf.
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
# #020 Host-zu-Host-Assistent und Verbindungstest
|
# #020 Host-zu-Host-Assistent und Verbindungstest
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** ui, backend
|
**Labels:** ui, backend
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 50 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Geführter Ablauf: Schlüssel erzeugen, Private auf A, Public auf B, Host-Key von B in known_hosts von A, Verbindungstest.
|
Geführter Ablauf: Schlüssel erzeugen, Private auf A, Public auf B, Host-Key von B in known_hosts von A, Verbindungstest.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Schrittanzeige
|
- [x] Schrittanzeige
|
||||||
- [ ] Zusammenfassung + Dry-Run
|
- [x] Zusammenfassung + Dry-Run
|
||||||
- [ ] `ssh -o BatchMode=yes … true` auf A
|
- [x] `ssh -o BatchMode=yes … true` auf A
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Kern-Anwendungsfall Ende-zu-Ende gegen zwei Test-Container
|
- [x] Kern-Anwendungsfall Ende-zu-Ende gegen zwei Test-Container
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#019
|
#019
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Fünf Schritte:** Vorlage & Zweck, Quelle, Ziel (Zielbenutzer, optional anlegen mit sudo, Einschränkungen, Alias, HostName/Port aus Sicht von A, Fingerprint von B bestätigen), Zusammenfassung, Ausrollen (Dry-Run, anwenden, Verbindungstest).
|
||||||
|
- **Vorlage** füllt Zielbenutzer, Anlegen/sudo, Ablaufdatum, `from=`-Muster und Alias vor; Platzhalter werden mit A und B aufgelöst. Ist die Adresse von A keine IP, fragt der Assistent nach.
|
||||||
|
- **Wiederaufnehmbar:** Erledigte Schritte (Schlüssel, Zielkonto, Zuweisungen) werden beim „Fortsetzen“ übersprungen. Braucht das Zielkonto eine frische Bestätigung, geschieht sie in einem neuen Tab – die Eingaben bleiben stehen.
|
||||||
|
- **root als Ziel:** deutlicher Hinweis, dass ein Agent wie Claude Code dann uneingeschränkten Zugriff hätte.
|
||||||
|
- **Verbindungstest:** `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes <alias> true` auf A als Zielbenutzer.
|
||||||
|
- E2E: Vorlage „Deployment-Zugang“ → `deploy` auf B angelegt → A meldet sich als deploy bei B an.
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
# #021 Abgleich und Drift-Erkennung
|
# #021 Abgleich und Drift-Erkennung
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** backend
|
**Labels:** backend
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 45 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Periodischer und manueller Abgleich, Befunde je Host/Benutzer.
|
Periodischer und manueller Abgleich, Befunde je Host/Benutzer.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Zeitplan konfigurierbar
|
- [x] Zeitplan konfigurierbar
|
||||||
- [ ] Befundarten
|
- [x] Befundarten
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Integrationstest
|
- [x] Integrationstest
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#016
|
#016
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Ein SSH-Aufruf je Host** liest `authorized_keys` aller Benutzer mit UID 0 oder ≥ 1000 plus aller Benutzer mit Zuweisungen – als jeweiliger Benutzer, base64-kodiert (Zeilenenden und fremde Bytes kommen unversehrt an). Dazu Rechte und Eigentümer von `~/.ssh` und Datei.
|
||||||
|
- **Befunde** (rein, `src/lib/ssh/abgleich.ts`): fehlender Key im Block, Block von Hand geändert (zusätzlicher Key oder andere Optionen), unbekannter Key außerhalb (bekannte Keys außerhalb sind kein Befund), Rechte (für Gruppe/Welt beschreibbar oder fremder Eigentümer), beschädigter Block, Private Key fehlt oder verändert (Hash gegen den versiegelten Key).
|
||||||
|
- **Abweichungen** werden geöffnet und – sobald behoben – geschlossen, nie doppelt. Host-Key-Abweichungen bleiben bei #009.
|
||||||
|
- **Zustände:** Public-Verwendungen AUSGEROLLT ↔ ABWEICHUNG, GEPLANT bleibt bis zum Ausrollen; Host SYNCHRON/DRIFT, letzter Abgleich, erreichbar.
|
||||||
|
- **Zeitplan** über pg-boss (`SSHKV_SYNC_CRON`, Standard `30 2 * * *`, Europe/Berlin) – liegt in der Datenbank, feuert also auch bei mehreren Workern nur einmal. Hosts mit laufendem Abgleich werden übersprungen.
|
||||||
|
- **Ändert nichts auf dem Host.** Korrekturen laufen über Dry-Run und Ausrollen.
|
||||||
|
- `DATEI_GEAENDERT` meldet nur einen beschädigten Block; eine bloße Hash-Änderung ist kein Befund, weil sshkv selbst schreibt.
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
# #022 Import bestehender Schlüssel
|
# #022 Import bestehender Schlüssel
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Unbekannte Schlüssel eines Hosts übernehmen und zuordnen.
|
Unbekannte Schlüssel eines Hosts übernehmen und zuordnen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] wird zu Beginn der Phase verfeinert
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] wird zu Beginn der Phase verfeinert
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#021
|
#021
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Grundlage ist die letzte Beobachtung** des Abgleichs: Übernommen wird nur, was sshkv dort wirklich gesehen hat.
|
||||||
|
- **Zwei Wege:** „bekannt“ (nur als Schlüssel aufnehmen – der Befund verschwindet, auf dem Host ändert sich nichts) oder „verwalten“ (Zuweisung mit `ausserhalbEntfernen`: beim nächsten Ausrollen wandert die Zeile mit ihren Optionen in den Block, die alte Zeile außerhalb verschwindet – mit Dry-Run).
|
||||||
|
- Neues Feld `PublicVerwendung.ausserhalbEntfernen` (Migration `20261012131000`). Ohne dieses Feld fasst sshkv Zeilen außerhalb des Blocks weiterhin nie an.
|
||||||
|
- Optionen der Zeile werden mit `optionenPruefen` übernommen; unbekannte Optionen verhindern das Verwalten mit klarer Meldung.
|
||||||
|
|||||||
@@ -1,19 +1,46 @@
|
|||||||
# #023 Geführte Rotation
|
# #023 Geführte Rotation
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Neuer Schlüssel an allen Verwendungen, Test, alter entfernt.
|
Neuer Schlüssel an allen Verwendungen, Test, alter entfernt.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] „Rotieren“ auf der Schlüsselseite, Schrittfolge `/schluessel/[id]/rotieren` (wiederaufnehmbar)
|
||||||
|
- [x] Neuer Schlüssel gleichen Typs, Name „… (rotiert TT.MM.JJJJ)“, `vorgaengerId` = alter
|
||||||
|
- [x] Zuweisungen übernehmen: Public gleiche Ziele/Optionen, Private gleicher Host/Benutzer/Alias mit neuem Pfad
|
||||||
|
- [x] Dry-Run + Ausrollen, Verbindungstests, erst dann den alten widerrufen (#024) → `ROTIERT`
|
||||||
|
- [x] Personen-Key ohne Private Key: nur Hinweis „neuer Public Key nötig“
|
||||||
|
- [x] Rotationskette auf der Schlüsselseite (Vorgänger/Nachfolger verlinkt)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] DB-Tests: Schritte, Reihenfolge, Rechte, Namen/Pfade (`tests/rotation.test.ts`)
|
||||||
|
- [x] Integrationstest: neuer Schlüssel funktioniert, alter von beiden Hosts weg, `ssh <alias>` geht weiter
|
||||||
|
- [x] E2E im Browser (`tests/e2e/rotation.spec.ts`), Screenshot `rotation-desktop.png`
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#020
|
#020, #024
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Fünf Schritte:** Neuer Schlüssel → Zuweisungen übernehmen → Dry-Run & Ausrollen → Verbindungstests →
|
||||||
|
Alten widerrufen. Der aktuelle Schritt wird aus der Datenbank errechnet (`rotationsStand`), deshalb
|
||||||
|
lässt sich die Rotation jederzeit neu laden und fortsetzen; jeder Schritt ist idempotent.
|
||||||
|
- **Kette sofort:** Der Worker setzt `vorgaengerId` schon beim Erzeugen (neues Feld in der
|
||||||
|
Vorgangs-Eingabe) – auch ein abgebrochener Browser hinterlässt keinen verwaisten Schlüssel.
|
||||||
|
`vorgaengerId` ist eindeutig: zwei gleichzeitige Rotationen scheitern verständlich.
|
||||||
|
- **Gleicher Alias, zwei Zuweisungen:** `privat.ts` schreibt je Alias genau einen config-Eintrag, die
|
||||||
|
jüngste Zuweisung gewinnt (an der Position der ersten). So zeigt `ssh <alias>` nach dem Ausrollen auf
|
||||||
|
die neue Datei, während die alte bis zum Widerruf daneben liegt.
|
||||||
|
- **Neuer Pfad:** `<alter Pfad ohne -rJJJJMMTT>-rJJJJMMTT`, bei Kollision mit laufender Nummer –
|
||||||
|
auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index über alle Zustände).
|
||||||
|
- **Grün heißt aktuell:** Der alte Schlüssel wird nur widerrufen, wenn für jede Private-Zuweisung mit
|
||||||
|
Alias ein erfolgreicher `VERBINDUNGSTEST` nach dem letzten Ausrollen vorliegt (Server prüft das).
|
||||||
|
Ohne Private-Zuweisung mit Alias entfällt der Test.
|
||||||
|
- **Abschluss:** Widerruf aus #024 ohne Zielkonten (der Nachfolger nutzt sie weiter); ein aktiver
|
||||||
|
Nachfolger macht aus `WIDERRUFEN` den Status `ROTIERT`.
|
||||||
|
- Ablaufdatum: Der Nachfolger erbt es nur, wenn es in der Zukunft liegt. Eine Passphrase wird nicht
|
||||||
|
übernommen (sshkv erzeugt ohne).
|
||||||
|
- Rechte: `schluesselVerwalten` + `publicKeyAusrollen`, bei Private-Zuweisungen `privateKeyAusrollen`.
|
||||||
|
Deployment-Key: eigene Rotation (später).
|
||||||
|
|||||||
@@ -1,19 +1,49 @@
|
|||||||
# #024 Widerruf und Massenwiderruf
|
# #024 Widerruf und Massenwiderruf
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** backend, ui, sicherheit
|
**Labels:** backend, ui, sicherheit
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Ein-Klick-Widerruf inkl. Benutzer/sudoers; Massenwiderruf mit Re-Auth.
|
Ein-Klick-Widerruf inkl. Benutzer/sudoers; Massenwiderruf mit Re-Auth.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] „Widerrufen“ auf der Schlüsselseite mit Dialog, der jede Entfernung auflistet
|
||||||
|
- [x] Public- und Private-Verwendungen → ENTFERNEN, Dry-Run, anwenden, erst dann `WIDERRUFEN`
|
||||||
|
- [x] Zielkonten optional entfernen (nur `vonSshkvAngelegt`, nie ein noch genutztes)
|
||||||
|
- [x] Massenwiderruf auf der Schlüsselliste, ein gemeinsamer Dry-Run, Protokoll je Schlüssel
|
||||||
|
- [x] Abgelaufene Schlüssel erkennbar (Filter) und Widerruf anbieten – ohne automatisches Entfernen
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] DB-Tests: Zustände, Rechte, Zielkonten, Protokoll ohne Geheimnisse (`tests/widerruf.test.ts`)
|
||||||
|
- [x] Integrationstest gegen zwei Hosts inkl. „Host nicht erreichbar“ (`tests/integration/widerruf.test.ts`)
|
||||||
|
- [x] E2E im Browser (`tests/e2e/widerruf.spec.ts`), Screenshot `widerruf-desktop.png`
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#018
|
#018
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Zwischenzustand statt Sofort-Status:** neues Feld `Schluessel.widerrufEingeleitetAm` (Migration
|
||||||
|
`20261012130000_widerruf_eingeleitet`). Der Status bleibt AKTIV/ABGELAUFEN, bis keine Verwendung mehr
|
||||||
|
offen ist; erst `widerrufAbschliessen` setzt `WIDERRUFEN` (bzw. `ROTIERT`, wenn ein aktiver Nachfolger
|
||||||
|
existiert, #023). Schlüsselseite und Liste zeigen „Widerruf unvollständig“ und die offenen Ziele.
|
||||||
|
- **Reihenfolge:** vormerken → Dry-Run/Anwenden (bestehende Hash-Sperre aus #016/#017) → optional
|
||||||
|
Zielkonten (`ZIELKONTO_ENTFERNEN`, erst wenn die Schlüssel überall weg sind) → abschließen.
|
||||||
|
Ein Ziel, das im Dry-Run scheitert (nicht erreichbar), wird nicht angewendet; seine Verwendung bleibt
|
||||||
|
ENTFERNEN, ein neuer Dry-Run versucht es erneut. Der Abschluss läuft nach erfolgreichem Anwenden
|
||||||
|
automatisch und meldet, was noch fehlt.
|
||||||
|
- **Nichts zu schreiben:** Steht der Schlüssel schon nirgends mehr, bietet das Dry-Run-Panel
|
||||||
|
„Zustand übernehmen“ an (sonst bliebe der Widerruf hängen).
|
||||||
|
- **Archivierte Hosts:** sshkv verbindet sich dort nicht mehr – deren Verwendungen gelten sofort als
|
||||||
|
entfernt; der Dialog sagt das, das Protokoll nennt die Hosts.
|
||||||
|
- **Zielkonten:** Ein Zielkonto kennt seinen Schlüssel nicht; Kandidat ist ein von sshkv angelegtes Konto
|
||||||
|
auf (Host, Benutzer) einer Zuweisung der widerrufenen Schlüssel. Nutzt ein anderer, nicht im Widerruf
|
||||||
|
befindlicher Schlüssel das Konto noch, bleibt es (Dialog nennt den Schlüssel).
|
||||||
|
- **Rechte:** nur Public Keys → `publicKeyAusrollen`; Private Keys → `privateKeyAusrollen` (Admin);
|
||||||
|
mehrere Schlüssel → `massenwiderruf`, Zielkonten → `zielkontenVerwalten` (jeweils Admin mit frischer
|
||||||
|
Bestätigung). Der Deployment-Key ist nicht widerrufbar. Ein Schlüssel im Widerruf lässt sich nicht
|
||||||
|
mehr zuweisen.
|
||||||
|
- **Ablauf:** Status-Filter „abgelaufen“ umfasst aktive Schlüssel mit vergangenem Ablaufdatum; die
|
||||||
|
Schlüsselseite bietet dann „Jetzt widerrufen“. Entfernt wird nie automatisch.
|
||||||
|
- Massenwiderruf als GET-Formular `/schluessel/widerruf?id=…` – die Auswahl der Zielkonten reist als
|
||||||
|
`?zielkonten=` mit und übersteht Neuladen und erneute Bestätigung.
|
||||||
|
|||||||
@@ -1,19 +1,46 @@
|
|||||||
# #025 Zuweisungsmatrix
|
# #025 Zuweisungsmatrix
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.4.0
|
**Meilenstein:** v0.4.0
|
||||||
**Labels:** ui
|
**Labels:** ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0,5 h
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Matrix Schlüssel × Host.
|
Matrix Schlüssel × Host.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] `/zuweisungen` ersetzt die Platzhalterseite: Matrix aktive Schlüssel (Zeilen) × nicht archivierte Hosts (Spalten), jede Rolle darf lesen
|
||||||
|
- [x] Zelle: „P“ direkt, „P·G“ über Hostgruppe, „K“ Private Key; Zustand als Punkt + Kurztext, voller Text in `title` und für Screenreader; Zielbenutzer mono
|
||||||
|
- [x] Filter per GET: Hostgruppe, Kategorie, Person, Suche über Schlüssel- und Hostnamen, „nur Zeilen/Spalten mit Zuweisungen“
|
||||||
|
- [x] Eigener Scrollbereich mit sticky Kopfzeile und erster Spalte; unter `md` eine Liste je Schlüssel
|
||||||
|
- [x] Zell-Dialog: Verwendungen mit Zustand, Optionen/Pfad, Links zu Schlüssel und Host; „Public Key zuweisen“ (vorhandenes Formular mit Vorauswahl) und „Entfernen“ für `publicKeyAusrollen`; Link „Zum Dry-Run“
|
||||||
|
- [x] Kopf: Zähler (Schlüssel, Hosts, Zuweisungen, nicht synchron) und CSV-Export `/api/zuweisungen.csv`
|
||||||
|
- [x] Reine Matrix-Funktion in `src/lib/zuweisungsmatrix.ts`, CSV-Helfer in `src/lib/csv.ts`, Query in `src/server/queries/zuweisungen.ts`
|
||||||
|
- [x] Tests: `tests/zuweisungsmatrix.test.ts` (rein), `tests/zuweisungen.test.ts` (Query + CSV-Route), E2E `tests/e2e/zuweisungen.spec.ts`
|
||||||
|
- [x] Screenshots `zuweisungen-desktop.png` / `zuweisungen-mobil.png`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Gruppen-Zuweisungen erscheinen auf jedem Mitglied und sind von direkten unterscheidbar
|
||||||
|
- [x] Zustand nie nur über Farbe
|
||||||
|
- [x] Seite scrollt bei 390 px nicht horizontal (Screenshot-Skript prüft das)
|
||||||
|
- [x] Leser sehen die Matrix, aber weder Zuweisen noch Entfernen
|
||||||
|
- [x] CSV mit den Spalten Schlüssel, Fingerprint, Host, Zielbenutzer, Art, Über Gruppe, Optionen, Zustand; Formelbeginne `= + - @` mit `'` entschärft
|
||||||
|
- [x] Route Handler prüft `rechtErforderlich("lesen")` selbst (`tests/zugang.test.ts` grün)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#016
|
#016
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- Die Matrix plant nur: Zuweisen und Entfernen nutzen die vorhandenen Aktionen, ausgerollt wird
|
||||||
|
weiter über den Dry-Run der Schlüsselseite (`AusrollenPanel`).
|
||||||
|
- Suche: trifft der Begriff Schlüssel, werden die Zeilen eingeschränkt; trifft er Hosts, die
|
||||||
|
Spalten; trifft er beides, beides. So zeigt „srv-pve“ alle Schlüssel auf diesen Hosts statt
|
||||||
|
einer leeren Matrix.
|
||||||
|
- Zähler „Zuweisungen“ zählt Zelleneinträge, eine Gruppen-Zuweisung also einmal je Mitglied –
|
||||||
|
dasselbe, was der CSV-Export als Zeilen liefert. „Nicht synchron“ ist alles außer `AUSGEROLLT`.
|
||||||
|
- Zellenzustand ist der dringendste der Einträge (Fehler > Drift > entfernen > geplant > synchron).
|
||||||
|
- CSV: Semikolon, UTF-8-BOM und CRLF für ein deutsches Excel; bei Private Keys steht der Pfad
|
||||||
|
als „Pfad: …“ in der Spalte Optionen. Der Export nimmt dieselben Filter wie die Seite.
|
||||||
|
- Deployment-Keys erscheinen als Zeilen, sind aber nicht zuweisbar (verwaltet sshkv selbst).
|
||||||
|
- Entfernen einer Gruppen-Zuweisung aus einer Zelle gilt für die ganze Gruppe; der Dialog sagt das.
|
||||||
|
- `PublicZuweisenFormular` hat eine optionale `vorauswahl`; `VerwendungsZustand` exportiert seine
|
||||||
|
Darstellungstabelle (`ZUSTAND_ANZEIGE`), damit die Zelle dieselben Punkte nutzt.
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
# #026 Übersicht (Dashboard)
|
# #026 Übersicht (Dashboard)
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** ui
|
**Labels:** ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen.
|
Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen aus der Datenbank
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen aus der Datenbank
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#021
|
#021
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Alles berechnet, nichts hinterlegt** (`src/server/queries/uebersicht.ts`): Kacheln und Listen können einander nicht widersprechen. Die Prototyp-Beispieldaten sind entfernt.
|
||||||
|
- **Hosts mit Abweichung** fasst Befunde je Host und Benutzer zusammen („2 unbekannte Schlüssel · Rechte falsch“, feste Reihenfolge) und zeigt nicht erreichbare Hosts; Link direkt auf die Hostseite.
|
||||||
|
- **Bald ablaufend** zeigt auch bereits abgelaufene, noch aktive Schlüssel („abgelaufen · noch ausgerollt“ in Koralle).
|
||||||
|
- **Letzte Aktionen** ohne Anmeldungen – die stehen im Audit-Log.
|
||||||
|
- Knöpfe nur für passende Rollen (Abgleich: Hosts verwalten; Assistent: Private Keys ausrollen).
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
# #027 Protokollansicht, CSV-Export, Syslog
|
# #027 Protokollansicht, CSV-Export, Syslog
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** ui, backend
|
**Labels:** ui, backend
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog.
|
Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] `/protokoll` statt Platzhalter, jede Rolle liest: Tabelle mit Zeitpunkt (TT.MM.JJJJ HH:MM:SS), Aktion (lesbar, farbig), Akteur, Host, Zielbenutzer, Fingerprint (gekürzt), Ergebnis; unter `md` Karten
|
||||||
|
- [x] Zeile aufklappbar: Diff im Diff-Box-Stil, Details als Liste (Werte als JSON), Hash und Vorgänger-Hash
|
||||||
|
- [x] Lesbare Texte für alle Aktionen in `src/lib/protokoll-aktionen.ts`, gruppiert (Anmeldung, Host-Key, Ausrollen, Schlüssel, Widerruf/Rotation, Zielkonto, Abgleich, Hosts, Personen und Vorlagen, Protokoll)
|
||||||
|
- [x] Filter per GET: Zeitraum (Berliner Kalendertage), Aktionsgruppe, Akteur, Host, Ergebnis, Freitext (Fingerprint/Host/Benutzer)
|
||||||
|
- [x] 50 je Seite, „Ältere laden“ hängt per Server Action an; Cursor über `id`, kein `OFFSET`; `?vor=<id>` als Einstieg
|
||||||
|
- [x] Kopfzeile mit Anzahl und Syslog-Ziel; „Kette prüfen“ (Server Action) mit Ergebnis-Zwischenspeicher für 5 Minuten, Warnung mit Link auf die erste fehlerhafte ID
|
||||||
|
- [x] CSV-Export `/api/protokoll.csv`: eigene Rechteprüfung, gleiche Filter, gestreamt in Stapeln zu 500, Formel-Entschärfung über `src/lib/csv.ts`, Export selbst als `protokoll.exportiert` protokolliert
|
||||||
|
- [x] Kein Lösch-/Bearbeitungsweg; struktureller Test gegen `protokoll.update/delete…` und `UPDATE/DELETE/TRUNCATE "Protokoll"` in `src/`
|
||||||
|
- [x] Syslog nach RFC 5424 (`src/lib/syslog.ts`, UDP oder TCP mit Oktettzählung, ohne neue Abhängigkeit), eingehängt in `protokollieren()` nach dem Schreiben
|
||||||
|
- [x] `.env.example` und `docker-compose.yml` (web und worker) um `SSHKV_SYSLOG_*`
|
||||||
|
- [x] Tests: Query/Cursor/Filter, CSV-Route, Kettenprüfung, Syslog (Format, Escaping, Länge, UDP-/TCP-Empfänger), Aktionstexte, Struktur; E2E `tests/e2e/protokoll.spec.ts`
|
||||||
|
- [x] Screenshots `protokoll-desktop.png` und `protokoll-mobil.png`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Jede `aktion: "…"` im Quellcode hat einen lesbaren Text (Test)
|
||||||
|
- [x] Filter und „Ältere laden“ liefern weder Lücken noch Doppelte, auch wenn zwischendurch Einträge dazukommen
|
||||||
|
- [x] CSV ohne Sitzung → 403; Kopfzeile `id;zeitpunkt;akteur;aktion;…;hash`; große Protokolle werden in Stapeln gelesen
|
||||||
|
- [x] Manipulierte Zeile → Warnung mit ID; unversehrte Kette → „Hash-Kette unversehrt“
|
||||||
|
- [x] Ausgefallener Syslog-Server verhindert keine Aktion; Fehler höchstens einmal je Minute im Log
|
||||||
|
- [x] 1440 und 390 px ohne Querscrollen
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#005
|
#005
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Kettenprüfung nur auf Knopfdruck, 5 Minuten zwischengespeichert (je Prozess).** `protokoll_kette_pruefen()` rechnet SHA-256 über jede Zeile und wächst linear mit dem Protokoll; beim bloßen Seitenaufruf wäre das bei 100 000 Einträgen spürbar. Gleichzeitige Klicks teilen sich eine laufende Prüfung. Ein Bruch wird selbst als `protokoll.kette_beschaedigt` (FEHLER) protokolliert, damit er auch beim Syslog ankommt.
|
||||||
|
- **CSV gestreamt** über einen `ReadableStream` mit `pull`: je Stapel 500 Zeilen (Cursor über `id`), der nächste Stapel erst, wenn der Client den vorigen abgenommen hat. Der Exporteintrag wird vor dem ersten Byte geschrieben – scheitert er, gibt es keinen Export.
|
||||||
|
- **IDs sind nicht lückenlos** (Spalten-Default und Trigger ziehen beide aus der Sequenz); Cursor und Tests verlassen sich nur auf die Ordnung.
|
||||||
|
- **Syslog:** RFC 5424, SD-ID `sshkv@32473`, Parameter id/akteur/aktion/ergebnis/host/zielbenutzer/fingerprint/hash (escaped nach §6.3.3, Steuerzeichen ersetzt, je Wert ≤ 120 Byte, Zeile ≤ 2048 Byte), MSGID = Aktion, Severity ERFOLG=notice, VERWEIGERT=warning, FEHLER=err, Facility standardmäßig `authpriv` (`SSHKV_SYSLOG_FACILITY`). Diff und Details gehen nie hinaus. Fire-and-forget nach dem `create`; innerhalb einer Transaktion kann ein späterer Rollback eine Syslog-Zeile ohne Gegenstück hinterlassen – das ist die harmlosere Richtung (ihr Hash fehlt dann in der Kette).
|
||||||
|
- `--ok` nur für erfolgreiche Aktionen, die etwas ändern; Anmeldungen, Abrufe und Export bleiben neutral, Fehler/Verweigert immer `--danger`.
|
||||||
|
|||||||
@@ -1,19 +1,65 @@
|
|||||||
# #028 Benachrichtigungen
|
# #028 Benachrichtigungen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** backend
|
**Labels:** backend
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0,5 h
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
E-Mail (M365 SMTP) und Webhook bei Drift und Ablauf.
|
E-Mail (M365 SMTP) und Webhook bei Drift und Ablauf.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Einstellungsseite `/einstellungen` statt Platzhalter: lesen alle Rollen, ändern nur
|
||||||
|
`einstellungenVerwalten` (Admin). Abschnitte „Benachrichtigungen“, „Abgleich“ (Zeitpläne aus
|
||||||
|
`SSHKV_SYNC_CRON`/`SSHKV_MELDUNG_CRON`, nur Anzeige) und „Instanz“ (Version,
|
||||||
|
Deployment-Key-Fingerprint, OIDC-Anbieter, TOTP des Notfall-Admins). Platz für „Syslog“ markiert.
|
||||||
|
- [x] Kanal E-Mail (SMTP): Vorbelegung Microsoft 365 (`smtp.office365.com:587`, STARTTLS), wahlweise
|
||||||
|
TLS (465) oder unverschlüsselt (nur internes Relay, mit Warnung); Absender, Benutzer, Passwort,
|
||||||
|
Empfängerliste.
|
||||||
|
- [x] Kanal Webhook: JSON per POST, optional `X-Sshkv-Signatur: sha256=<HMAC>` über den Body; nur
|
||||||
|
`https://`, `http://` nur per Häkchen mit Warnung; keine Weiterleitungen, Zeitlimit 15 s.
|
||||||
|
- [x] Geheimnisse (SMTP-Passwort, Webhook-Geheimnis) mit `verschluesseln()` in `Einstellung`;
|
||||||
|
die Oberfläche bekommt nur „gesetzt“, leeres Feld behält, Häkchen entfernt.
|
||||||
|
- [x] „Testnachricht senden“ je Kanal als Vorgang `BENACHRICHTIGUNG_TEST` im Worker.
|
||||||
|
- [x] Anlässe (je einzeln schaltbar): neue Abweichungen je Host, Ablauf in 30/14/7 Tagen,
|
||||||
|
abgelaufen aber ausgerollt, Host nicht erreichbar – gesammelt in der täglichen Zusammenfassung
|
||||||
|
(`SSHKV_MELDUNG_CRON`, Standard `0 7 * * *`); geänderter Host-Key sofort (Takt alle 5 Minuten).
|
||||||
|
- [x] Versandprotokoll `Zustellung` (Migration `20261012161000_benachrichtigungen`) mit
|
||||||
|
Anlass-Fingerabdruck, Kanal, Ergebnis, Fehler, Versuchen; Prüfung vor jedem Versand,
|
||||||
|
Wiederholung nach 5/10/20/40 Minuten (höchstens 5 Versuche); „Letzte Zustellungen“ auf der Seite.
|
||||||
|
- [x] Jede Zustellung in `protokollieren()` als `benachrichtigung.gesendet`/`…fehlgeschlagen`
|
||||||
|
(Empfänger nur gezählt, Webhook nur Schema und Host).
|
||||||
|
- [x] Zeitpläne in `src/worker/index.ts`; `.env.example` und `docker-compose.yml` ergänzt.
|
||||||
|
- [x] Tests: Unit (Nachrichtenaufbau, Signatur, Stufen, Deduplizierung, Prüfungen), DB
|
||||||
|
(Einstellungen, Versandprotokoll), Integration gegen Mailpit und lokalen HTTP-Empfänger,
|
||||||
|
E2E `tests/e2e/einstellungen.spec.ts`; Screenshots `einstellungen-desktop/-mobil`.
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Admin speichert SMTP-Einstellungen, die Testnachricht kommt in Mailpit an (E2E).
|
||||||
|
- [x] Das Passwort steht nie im Klartext in Datenbank, Protokoll oder Seite; das Feld zeigt nur „gesetzt“.
|
||||||
|
- [x] Leser sieht die Seite ohne Ändern- und Test-Knöpfe.
|
||||||
|
- [x] Dieselbe Abweichung, dieselbe Ablaufstufe und derselbe Ausfall werden je Kanal genau einmal gemeldet.
|
||||||
|
- [x] Fehlgeschlagene Zustellungen werden begrenzt wiederholt und sind in der Oberfläche sichtbar.
|
||||||
|
- [x] Ein geänderter Host-Key wird innerhalb von fünf Minuten gemeldet, ohne den Abgleich-Ablauf zu ändern.
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#021
|
#021
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Versand im Worker**, auch für die Testnachricht: der Webprozess braucht so keinen Weg zu
|
||||||
|
Mailserver oder Webhook-Ziel, und regulärer Versand und Test laufen über denselben Code. Die
|
||||||
|
Oberfläche verfolgt den Test wie andere Vorgänge über `VorgangsKnopf`.
|
||||||
|
- **Anlässe aus der Datenbank**, nicht aus dem Abgleich: `src/worker/ablaeufe/abgleich.ts` bleibt
|
||||||
|
unverändert; der Sammler liest offene `Abweichung`, `Host.erreichbar = false` und Ablaufdaten.
|
||||||
|
Ein verpasster Lauf holt sich der nächste.
|
||||||
|
- **Fingerabdrücke:** `drift:<abweichungId>`, `hostkey:<abweichungId>`,
|
||||||
|
`ablauf:<schluesselId>:<stufe>:<ablaufdatum>` (mit Datum, damit ein verlängerter Schlüssel neu
|
||||||
|
erinnert wird), `abgelaufen:<schluesselId>:<ablaufdatum>`,
|
||||||
|
`unerreichbar:<hostId>:<letzterAbgleichAm>` (ändert sich nur bei gelungenem Abgleich → eine Meldung
|
||||||
|
je Ausfall). Statt „seit der letzten Meldung“ zählt das Versandprotokoll – wer Benachrichtigungen
|
||||||
|
erstmals einschaltet, bekommt die offenen Abweichungen einmal gemeldet.
|
||||||
|
- Hosts mit offenem `HOSTKEY_GEAENDERT` erscheinen nicht zusätzlich als „nicht erreichbar“.
|
||||||
|
- Der Fünf-Minuten-Takt verschickt dringende Host-Key-Meldungen und fällige Wiederholungen;
|
||||||
|
Neues aus (a)–(d) wartet auf die Zusammenfassung. Läufe im selben Worker laufen nacheinander.
|
||||||
|
- Leser sehen statt der Webhook-Adresse nur Schema und Host (Tokens im Pfad).
|
||||||
|
- Mailpit für Tests mit `MP_SMTP_DISABLE_RDNS=true` starten, sonst wartet es je Verbindung 5 s.
|
||||||
|
- `nodemailer@10.0.10` wie in ProxLedger, exakt gepinnt.
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
# #029 Handbuch und Betriebsdokumentation
|
# #029 Handbuch und Betriebsdokumentation
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** docs
|
**Labels:** docs
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Installation, Bootstrap, Kern-Anwendungsfall mit Bildern; Bedrohungsanalyse, Backup-Konzept.
|
Installation, Bootstrap, Kern-Anwendungsfall mit Bildern; Bedrohungsanalyse, Backup-Konzept.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Handbuch mit Bildern (`docs/anleitung/README.md`, 12 Kapitel)
|
||||||
|
- [x] Installation, authentik, Reverse Proxy (`docs/betrieb/installation.md`)
|
||||||
|
- [x] Bedrohungsanalyse und Notfallplan (`docs/betrieb/sicherheit.md`)
|
||||||
|
- [x] Backup-Konzept mit Skripten und geprobter Wiederherstellung (`docs/betrieb/sicherung.md`)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Handbuch mit Bildern (`docs/anleitung/README.md`, 12 Kapitel)
|
||||||
|
- [x] Installation, authentik, Reverse Proxy (`docs/betrieb/installation.md`)
|
||||||
|
- [x] Bedrohungsanalyse und Notfallplan (`docs/betrieb/sicherheit.md`)
|
||||||
|
- [x] Backup-Konzept mit Skripten und geprobter Wiederherstellung (`docs/betrieb/sicherung.md`)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
–
|
–
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Wiederherstellung geprobt** auf einem Wegwerf-Stapel – dabei zwei Fehler gefunden und behoben (Schema pgboss mit `--clean` nicht rückspielbar; Abbruch ohne Transaktion löschte es halb). Jetzt: nur Schema `public`, eine Transaktion, Warteschlange neu, offene Vorgänge abgebrochen.
|
||||||
|
- **Master-Key wird nicht mitgesichert**, nur ein Prüfwert (SHA-256). Das Wiederherstellungsskript bricht vor jeder Änderung ab, wenn der bereitliegende Master-Key nicht passt; der Worker startet nicht mit einem falschen Master-Key.
|
||||||
|
- **Notfallplan „sshkv kompromittiert“** beschreibt das Entfernen des Deployment-Keys auf allen Hosts von Hand – eine eingebaute Rotation des Deployment-Keys gibt es noch nicht.
|
||||||
|
- Bilder entstehen aus `scripts/screenshots.ts` und den E2E-Abläufen.
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
# #030 Issues nach Gitea übertragen
|
# #030 Issues nach Gitea übertragen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** chore
|
**Labels:** chore
|
||||||
**Geschätzt:** 1 h **Tatsächlich:** –
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Issue-Dateien, Labels und Meilensteine in Gitea anlegen (wartet auf Zugangsdaten).
|
Issue-Dateien, Labels und Meilensteine in Gitea anlegen (wartet auf Zugangsdaten).
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] `scripts/gitea-issues.ts`
|
- [x] `scripts/gitea-issues.ts`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] wird zu Beginn der Phase verfeinert
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
–
|
–
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- Repo `florian.wilde/sshkeyvault` auf git.wilde-server.de (von Florian angelegt), `main` und Tags v0.1.0–v0.5.0 gepusht.
|
||||||
|
- `pnpm issues:sync` liest Adresse und Repo aus dem Git-Remote und den Token aus den Git-Zugangsdaten; die Issue-Dateien bleiben die Quelle der Wahrheit.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# #031 Rotation des Deployment-Keys
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v1.0.0
|
||||||
|
**Labels:** sicherheit, backend
|
||||||
|
**Geschätzt:** 3 h **Tatsächlich:** 1 h 10 min
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Den Deployment-Key der Instanz geführt tauschen – regelmäßig oder im Notfall (sshkv kompromittiert, siehe `docs/betrieb/sicherheit.md`).
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] Neuen Deployment-Key erzeugen, ohne den alten abzulösen (zweiter Instanz-Key im Übergang)
|
||||||
|
- [x] Auf jedem eingerichteten Host den neuen Key neben den alten in den verwalteten Block des Anmeldebenutzers schreiben (Dry-Run, Ausrollen)
|
||||||
|
- [x] Verbindung mit dem neuen Key je Host prüfen
|
||||||
|
- [x] Erst wenn alle Hosts den neuen Key akzeptieren: alten aus allen Blöcken entfernen, alten Key verwerfen
|
||||||
|
- [x] Nicht erreichbare Hosts sichtbar machen; Abschluss erst, wenn alle umgestellt sind (oder bewusst übergangen)
|
||||||
|
- [x] Nur Admin mit frischer Bestätigung, alles protokolliert
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- [x] Integrationstest: nach der Rotation meldet sich sshkv nur noch mit dem neuen Key an, der alte steht auf keinem Host mehr
|
||||||
|
- [x] Aussperrschutz: zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
#016, #021
|
||||||
|
|
||||||
|
## Notizen / Entscheidungen
|
||||||
|
- Der Nachfolger ist ein eigener Schlüssel (`kategorie DEPLOYMENT`, `vorgaengerId` = alter, noch nicht `instanzStandard`). `sollFuer()` nimmt ihn für alle Hosts mit dem Instanz-Key zusätzlich in Block **und** Pflichtliste – der normale Dry-Run verteilt ihn also, und der Aussperrschutz schützt beide.
|
||||||
|
- Hosts mit eigenem oder Gruppen-Deployment-Key nehmen nicht teil.
|
||||||
|
- Umstellen ist eine Transaktion (alter ROTIERT, neuer Standard) und verlangt eine Prüfung **nach** dem Erzeugen, in der jeder Host den neuen Key angenommen hat – oder bewusst übergangen wird (protokolliert).
|
||||||
|
- Abschließen prüft je Host, dass der alte Fingerprint aus dem Block verschwunden ist; erst dann wird sein Private Key verworfen. Unerreichbare Hosts blockieren den Abschluss nicht (sshkv benutzt den alten Key ohnehin nicht mehr), werden aber im Ergebnis genannt.
|
||||||
|
- Tests: Integrationstest gegen beide Testziele (DIENSTBENUTZER und ROOT, alte Anmeldung scheitert danach), E2E im Browser.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# #032 Nacharbeiten aus Phase 4/5
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v1.0.0
|
||||||
|
**Labels:** test, chore
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0,75 h
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC)
|
||||||
|
- [x] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv)
|
||||||
|
- [x] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`)
|
||||||
|
- [x] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker)
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- [x] Alle Suiten grün
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
#023, #024, #025, #027
|
||||||
|
|
||||||
|
## Notizen / Entscheidungen
|
||||||
|
- **Massenwiderruf:** Der neue E2E-Test (`widerruf.spec.ts`) fand einen echten Fehler: Die Seite
|
||||||
|
`/schluessel/widerruf` filterte `?id=` mit `/^[a-z0-9]{1,64}$/`. Vom Worker erzeugte Schlüssel tragen
|
||||||
|
eine UUID mit Bindestrichen – jeder erzeugte Schlüssel fiel still heraus („Keine Schlüssel gewählt“).
|
||||||
|
Jetzt `idsAusSuchparameter` in `src/lib/schluessel.ts` (mit Test). Außerdem nannte der Hinweis nach
|
||||||
|
verweigerter Bestätigung den falschen Knopf („Widerruf vorbereiten“ statt „2 Schlüssel widerrufen“).
|
||||||
|
`datenbank()` und `oidcBestaetigen()` liegen jetzt in `tests/e2e/hilfen.ts`.
|
||||||
|
- **Rotation abbrechen:** kein neues Feld. Abbrechen = Widerruf des Nachfolgers über den Ablauf aus #024
|
||||||
|
(Dry-Run, er kann schon ausgerollt sein); ein Nachfolger im Widerruf bzw. widerrufen *ist* die
|
||||||
|
abgebrochene Rotation (`rotationAbgebrochen`). `vorgaengerId` bleibt für die Historie, der Abbruch
|
||||||
|
steht als `schluessel.rotation.abgebrochen` im Protokoll – in derselben Transaktion wie das Vormerken
|
||||||
|
(`widerrufEinleiten` nimmt dafür optional `zusaetzlich` an). Der alte Schlüssel bleibt unverändert; bei
|
||||||
|
gleichem Alias zeigt die config nach dem Anwenden wieder auf die alte Datei (nur verbleibende
|
||||||
|
Zuweisungen landen im Block).
|
||||||
|
- Abbrechen geht nur, solange der alte Schlüssel noch nicht entfernt wird (Schritt 5) – sonst wären am
|
||||||
|
Ende beide halb entfernt. Auf dem Nachfolger ersetzt „Rotation abbrechen“ den normalen
|
||||||
|
„Widerrufen“-Knopf, damit der Abbruch immer im Protokoll steht.
|
||||||
|
- `widerrufAbschliessen` setzt `ROTIERT` nur noch bei einem aktiven Nachfolger, der nicht selbst im
|
||||||
|
Widerruf ist; die Seite schreibt „Ersetzt durch …“ nur bei `ROTIERT`.
|
||||||
|
- **Kein zweiter Nachfolger:** `vorgaengerId` ist eindeutig (1:1-Relation). Nach einem Abbruch lässt sich
|
||||||
|
derselbe Schlüssel deshalb nicht erneut über sshkv rotieren; Seite und Fehlermeldung sagen das
|
||||||
|
(neuen Schlüssel anlegen, zuweisen, alten widerrufen). Eine erneute Rotation bräuchte eine
|
||||||
|
1:n-Relation mit eindeutigem Teilindex „ein nicht widerrufener Nachfolger je Vorgänger“.
|
||||||
|
- **Protokoll-IDs:** Migration `20261012173000_protokoll_id_lueckenlos` setzt den Spalten-Default auf den
|
||||||
|
Platzhalter `0` (Prisma: `@default(0)`, `create` ohne ID geht weiter); die ID zieht nur noch der
|
||||||
|
Trigger unter der Advisory-Sperre aus der weiterhin zugeordneten Sequenz. Bestehende Zeilen bleiben
|
||||||
|
unverändert (die ID steckt in der Hash-Eingabe), alte Lücken bleiben also stehen. Eine mitgegebene ID
|
||||||
|
überschreibt der Trigger weiterhin.
|
||||||
|
- **Anker:** Die Dry-Run-Karte trägt `id="dry-run"` (`Karte` nimmt eine optionale `id`) und
|
||||||
|
`scroll-mt-20` wegen der festen Kopfleiste der schmalen Ansicht; „Zum Dry-Run“ zeigt auf
|
||||||
|
`/schluessel/<id>#dry-run`.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sshkv",
|
"name": "sshkv",
|
||||||
"version": "0.2.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "SSHKeyVault – zentrale Verwaltung von SSH-Schluesseln mit Weboberflaeche",
|
"description": "SSHKeyVault – zentrale Verwaltung von SSH-Schluesseln mit Weboberflaeche",
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"jose": "^6.2.12",
|
"jose": "^6.2.12",
|
||||||
"lucide-react": "1.47.0",
|
"lucide-react": "1.47.0",
|
||||||
"next": "16.3.6",
|
"next": "16.3.6",
|
||||||
|
"nodemailer": "10.0.10",
|
||||||
"openid-client": "^6.8.8",
|
"openid-client": "^6.8.8",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
"pg-boss": "^12.37.1",
|
"pg-boss": "^12.37.1",
|
||||||
@@ -71,6 +72,7 @@
|
|||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/node": "^24.19.2",
|
"@types/node": "^24.19.2",
|
||||||
|
"@types/nodemailer": "8.0.2",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.3.6
|
specifier: 16.3.6
|
||||||
version: 16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
version: 16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||||
|
nodemailer:
|
||||||
|
specifier: 10.0.10
|
||||||
|
version: 10.0.10
|
||||||
openid-client:
|
openid-client:
|
||||||
specifier: ^6.8.8
|
specifier: ^6.8.8
|
||||||
version: 6.8.8
|
version: 6.8.8
|
||||||
@@ -239,6 +242,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.19.2
|
specifier: ^24.19.2
|
||||||
version: 24.19.2
|
version: 24.19.2
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: 8.0.2
|
||||||
|
version: 8.0.2
|
||||||
'@types/pg':
|
'@types/pg':
|
||||||
specifier: ^8.23.1
|
specifier: ^8.23.1
|
||||||
version: 8.23.1
|
version: 8.23.1
|
||||||
@@ -1481,6 +1487,9 @@ packages:
|
|||||||
'@types/node@26.6.5':
|
'@types/node@26.6.5':
|
||||||
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
|
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.2':
|
||||||
|
resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==}
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||||
|
|
||||||
@@ -3067,6 +3076,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
|
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
nodemailer@10.0.10:
|
||||||
|
resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
non-error@0.1.0:
|
non-error@0.1.0:
|
||||||
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -4910,6 +4923,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.9.0
|
undici-types: 8.9.0
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.19.2
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 26.6.5
|
'@types/node': 26.6.5
|
||||||
@@ -6625,6 +6642,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.58: {}
|
node-releases@2.0.58: {}
|
||||||
|
|
||||||
|
nodemailer@10.0.10: {}
|
||||||
|
|
||||||
non-error@0.1.0: {}
|
non-error@0.1.0: {}
|
||||||
|
|
||||||
oauth4webapi@3.8.8: {}
|
oauth4webapi@3.8.8: {}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
-- This migration adds more than one value to an enum.
|
||||||
|
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||||
|
-- in a single migration. This can be worked around by creating
|
||||||
|
-- multiple migrations, each migration adding only one value to
|
||||||
|
-- the enum.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ANLEGEN';
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ENTFERNEN';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- #024: Zwischenzustand „Widerruf eingeleitet“. Der Schluessel bleibt AKTIV (bzw. ABGELAUFEN),
|
||||||
|
-- bis alle Verwendungen nachweislich entfernt sind; erst dann wird er WIDERRUFEN bzw. ROTIERT.
|
||||||
|
ALTER TABLE "Schluessel" ADD COLUMN "widerrufEingeleitetAm" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PublicVerwendung" ADD COLUMN "ausserhalbEntfernen" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- #028: Benachrichtigungen. Versandprotokoll gegen Doppelmeldungen und eine Vorgangsart fuer
|
||||||
|
-- die Testnachricht (Versand laeuft im Worker).
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MeldungsKanal" AS ENUM ('EMAIL', 'WEBHOOK');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ZustellErgebnis" AS ENUM ('GESENDET', 'FEHLGESCHLAGEN');
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'BENACHRICHTIGUNG_TEST';
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Zustellung" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"anlass" TEXT NOT NULL,
|
||||||
|
"kanal" "MeldungsKanal" NOT NULL,
|
||||||
|
"ergebnis" "ZustellErgebnis" NOT NULL,
|
||||||
|
"fehler" TEXT,
|
||||||
|
"versuche" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"naechsterVersuchAm" TIMESTAMP(3),
|
||||||
|
"sendung" TEXT NOT NULL,
|
||||||
|
"betreff" TEXT NOT NULL,
|
||||||
|
"zeitpunkt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Zustellung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Zustellung_zeitpunkt_idx" ON "Zustellung"("zeitpunkt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Zustellung_sendung_idx" ON "Zustellung"("sendung");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Zustellung_anlass_kanal_key" ON "Zustellung"("anlass", "kanal");
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- #032: Protokoll-IDs ohne Zweierschritte.
|
||||||
|
--
|
||||||
|
-- Bisher zog der Spalten-Default (BIGSERIAL) eine Nummer aus der Sequenz und der Trigger
|
||||||
|
-- `protokoll_verketten` gleich noch eine – jede Zeile verbrauchte zwei IDs. Die ID muss aber
|
||||||
|
-- im Trigger unter der Advisory-Sperre vergeben werden: Eine vorher gezogene Nummer koennte
|
||||||
|
-- kleiner sein als die einer parallelen Transaktion, die zuerst verkettet, und die Kette liesse
|
||||||
|
-- sich in ID-Reihenfolge nicht mehr pruefen. Deshalb verzichtet der Default auf die Sequenz.
|
||||||
|
--
|
||||||
|
-- 0 ist nur ein Platzhalter, damit `prisma.protokoll.create` ohne ID auskommt; der Trigger
|
||||||
|
-- ueberschreibt jede ID (auch eine mitgegebene). Die Sequenz bleibt der Spalte zugeordnet
|
||||||
|
-- (OWNED BY), deshalb findet `pg_get_serial_sequence` sie im Trigger weiterhin.
|
||||||
|
--
|
||||||
|
-- Bestehende Zeilen bleiben, wie sie sind: Die ID ist Teil der Hash-Eingabe, Umnummerieren
|
||||||
|
-- wuerde die Kette brechen. Die alten Luecken bleiben also stehen.
|
||||||
|
ALTER TABLE "Protokoll" ALTER COLUMN "id" SET DEFAULT 0;
|
||||||
@@ -110,6 +110,12 @@ enum VorgangsArt {
|
|||||||
SCHLUESSEL_IMPORTIEREN
|
SCHLUESSEL_IMPORTIEREN
|
||||||
/// Gespeicherten Private Key endgueltig verwerfen (#014).
|
/// Gespeicherten Private Key endgueltig verwerfen (#014).
|
||||||
PRIVATE_KEY_LOESCHEN
|
PRIVATE_KEY_LOESCHEN
|
||||||
|
/// Zielbenutzer anlegen und sudoers setzen (#018).
|
||||||
|
ZIELKONTO_ANLEGEN
|
||||||
|
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
|
||||||
|
ZIELKONTO_ENTFERNEN
|
||||||
|
/// Testnachricht ueber einen Benachrichtigungskanal (#028).
|
||||||
|
BENACHRICHTIGUNG_TEST
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VorgangsStatus {
|
enum VorgangsStatus {
|
||||||
@@ -121,6 +127,17 @@ enum VorgangsStatus {
|
|||||||
ABGEBROCHEN
|
ABGEBROCHEN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Versandweg einer Benachrichtigung (#028).
|
||||||
|
enum MeldungsKanal {
|
||||||
|
EMAIL
|
||||||
|
WEBHOOK
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ZustellErgebnis {
|
||||||
|
GESENDET
|
||||||
|
FEHLGESCHLAGEN
|
||||||
|
}
|
||||||
|
|
||||||
enum ProtokollErgebnis {
|
enum ProtokollErgebnis {
|
||||||
ERFOLG
|
ERFOLG
|
||||||
FEHLER
|
FEHLER
|
||||||
@@ -270,44 +287,47 @@ model HostSchluessel {
|
|||||||
// ---------------------------------------------------------------- Schluessel
|
// ---------------------------------------------------------------- Schluessel
|
||||||
|
|
||||||
model Schluessel {
|
model Schluessel {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
/// Zweck, z. B. „Backup srv-backup01 → srv-pve01“.
|
/// Zweck, z. B. „Backup srv-backup01 → srv-pve01“.
|
||||||
name String
|
name String
|
||||||
/// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01.
|
/// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01.
|
||||||
slug String @unique
|
slug String @unique
|
||||||
typ SchluesselTyp
|
typ SchluesselTyp
|
||||||
kategorie SchluesselKategorie
|
kategorie SchluesselKategorie
|
||||||
status SchluesselStatus @default(AKTIV)
|
status SchluesselStatus @default(AKTIV)
|
||||||
/// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar".
|
/// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar".
|
||||||
publicKey String
|
publicKey String
|
||||||
fingerprint String @unique
|
fingerprint String @unique
|
||||||
kommentar String
|
kommentar String
|
||||||
/// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts.
|
/// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts.
|
||||||
privatVerschluesselt Bytes?
|
privatVerschluesselt Bytes?
|
||||||
/// Data-Key, verschluesselt mit dem Master-Key.
|
/// Data-Key, verschluesselt mit dem Master-Key.
|
||||||
dekVerschluesselt Bytes?
|
dekVerschluesselt Bytes?
|
||||||
masterKeyVersion Int?
|
masterKeyVersion Int?
|
||||||
privatGeloeschtAm DateTime?
|
privatGeloeschtAm DateTime?
|
||||||
passphraseGeschuetzt Boolean @default(false)
|
passphraseGeschuetzt Boolean @default(false)
|
||||||
/// Genau ein Deployment-Key ist Instanz-Standard (partieller Index).
|
/// Genau ein Deployment-Key ist Instanz-Standard (partieller Index).
|
||||||
instanzStandard Boolean @default(false)
|
instanzStandard Boolean @default(false)
|
||||||
personId String?
|
personId String?
|
||||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||||
vorlageId String?
|
vorlageId String?
|
||||||
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
|
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
|
||||||
tags String[]
|
tags String[]
|
||||||
ablaufAm DateTime?
|
ablaufAm DateTime?
|
||||||
zuletztVerwendetAm DateTime?
|
zuletztVerwendetAm DateTime?
|
||||||
|
/// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
|
||||||
|
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
|
||||||
|
widerrufEingeleitetAm DateTime?
|
||||||
/// Rotationskette: der Schluessel, den dieser abgeloest hat.
|
/// Rotationskette: der Schluessel, den dieser abgeloest hat.
|
||||||
vorgaengerId String? @unique
|
vorgaengerId String? @unique
|
||||||
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
|
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
|
||||||
nachfolger Schluessel? @relation("Rotation")
|
nachfolger Schluessel? @relation("Rotation")
|
||||||
erstelltVonId String?
|
erstelltVonId String?
|
||||||
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
|
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
|
||||||
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
|
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
|
||||||
selbstHinterlegt Boolean @default(false)
|
selbstHinterlegt Boolean @default(false)
|
||||||
angelegtAm DateTime @default(now())
|
angelegtAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
|
|
||||||
publicVerwendungen PublicVerwendung[]
|
publicVerwendungen PublicVerwendung[]
|
||||||
privateVerwendungen PrivateVerwendung[]
|
privateVerwendungen PrivateVerwendung[]
|
||||||
@@ -329,6 +349,9 @@ model PublicVerwendung {
|
|||||||
zielbenutzer String
|
zielbenutzer String
|
||||||
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
|
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
|
||||||
optionen String?
|
optionen String?
|
||||||
|
/// Beim Ausrollen dieselbe Zeile ausserhalb des Blocks entfernen – nur fuer uebernommene
|
||||||
|
/// Keys (#022), sonst fasst sshkv fremde Zeilen nie an.
|
||||||
|
ausserhalbEntfernen Boolean @default(false)
|
||||||
zustand VerwendungsZustand @default(GEPLANT)
|
zustand VerwendungsZustand @default(GEPLANT)
|
||||||
zuletztAngewendetAm DateTime?
|
zuletztAngewendetAm DateTime?
|
||||||
angelegtVonId String?
|
angelegtVonId String?
|
||||||
@@ -460,7 +483,9 @@ model Vorgang {
|
|||||||
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
|
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
|
||||||
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
|
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
|
||||||
model Protokoll {
|
model Protokoll {
|
||||||
id BigInt @id @default(autoincrement())
|
/// Vergibt der Trigger `protokoll_verketten` unter der Advisory-Sperre aus der Sequenz; der
|
||||||
|
/// Default 0 ist nur ein Platzhalter, damit `create` ohne ID auskommt (#032).
|
||||||
|
id BigInt @id @default(0)
|
||||||
zeitpunkt DateTime @default(now())
|
zeitpunkt DateTime @default(now())
|
||||||
akteurId String?
|
akteurId String?
|
||||||
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
|
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
|
||||||
@@ -486,6 +511,30 @@ model Einstellung {
|
|||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Versandprotokoll der Benachrichtigungen (#028): je Anlass und Kanal hoechstens eine Zeile.
|
||||||
|
/// Vor jedem Versand wird hiergegen geprueft – so meldet sshkv dieselbe Abweichung oder dieselbe
|
||||||
|
/// Ablaufstufe nie zweimal, auch wenn ein Zeitplan doppelt feuert oder der Worker neu startet.
|
||||||
|
model Zustellung {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Fingerabdruck des Anlasses, z. B. drift:<abweichungId>, ablauf:<schluesselId>:7:<datum>.
|
||||||
|
anlass String
|
||||||
|
kanal MeldungsKanal
|
||||||
|
ergebnis ZustellErgebnis
|
||||||
|
/// Kurzer Fehlertext ohne Zugangsdaten.
|
||||||
|
fehler String?
|
||||||
|
versuche Int @default(1)
|
||||||
|
/// Wann ein Fehlschlag fruehestens erneut versucht wird; leer = nicht mehr.
|
||||||
|
naechsterVersuchAm DateTime?
|
||||||
|
/// Gemeinsame Kennung aller Anlaesse einer Nachricht – die Oberflaeche zeigt je Nachricht eine Zeile.
|
||||||
|
sendung String
|
||||||
|
betreff String
|
||||||
|
zeitpunkt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([anlass, kanal])
|
||||||
|
@@index([zeitpunkt])
|
||||||
|
@@index([sendung])
|
||||||
|
}
|
||||||
|
|
||||||
/// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster).
|
/// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster).
|
||||||
model Ratenbegrenzung {
|
model Ratenbegrenzung {
|
||||||
schluessel String @id
|
schluessel String @id
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Überträgt die Issue-Dateien aus `docs/issues/` in den Gitea-Issue-Tracker.
|
||||||
|
*
|
||||||
|
* GITEA_URL=https://git.wilde-server.de \
|
||||||
|
* GITEA_REPO=florian.wilde/sshkv \
|
||||||
|
* GITEA_TOKEN=... \
|
||||||
|
* npm run issues:sync
|
||||||
|
*
|
||||||
|
* Ohne Token oder mit `--dry-run` läuft nur ein Trockenlauf: Es wird ausgegeben, was passieren würde.
|
||||||
|
* Die Markdown-Dateien bleiben die Quelle der Wahrheit; Gitea erhält eine Kopie zum Kommentieren.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
|
import "dotenv/config";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
interface IssueDatei {
|
||||||
|
nummer: number;
|
||||||
|
titel: string;
|
||||||
|
status: string;
|
||||||
|
meilenstein: string | null;
|
||||||
|
labels: string[];
|
||||||
|
koerper: string;
|
||||||
|
dateiname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Farben der Labels, angelehnt an die Design-Tokens (#030). */
|
||||||
|
const LABEL_FARBEN: Record<string, string> = {
|
||||||
|
backend: "#6aa8ff",
|
||||||
|
ui: "#f0b43c",
|
||||||
|
datenbank: "#3cc9a7",
|
||||||
|
sicherheit: "#f2714b",
|
||||||
|
test: "#3cc9a7",
|
||||||
|
docs: "#8b96a3",
|
||||||
|
infrastruktur: "#2a323b",
|
||||||
|
chore: "#8b96a3",
|
||||||
|
};
|
||||||
|
|
||||||
|
function feld(inhalt: string, name: string): string | null {
|
||||||
|
const treffer = new RegExp(`^\\*\\*${name}:\\*\\*\\s*(.+)$`, "m").exec(inhalt);
|
||||||
|
return treffer ? (treffer[1] ?? "").trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dateienLesen(verzeichnis: string): Promise<IssueDatei[]> {
|
||||||
|
const namen = (await readdir(verzeichnis)).filter((name) => /^\d{3}-.*\.md$/.test(name)).sort();
|
||||||
|
|
||||||
|
const issues: IssueDatei[] = [];
|
||||||
|
for (const dateiname of namen) {
|
||||||
|
const inhalt = await readFile(path.join(verzeichnis, dateiname), "utf8");
|
||||||
|
const kopf = /^#\s*#(\d+)\s+(.+)$/m.exec(inhalt);
|
||||||
|
if (!kopf) {
|
||||||
|
console.warn(`Übersprungen (keine Überschrift „# #NNN Titel“): ${dateiname}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Körper beginnt nach den Kopfzeilen (Status, Meilenstein, Labels, Zeiten).
|
||||||
|
const körperStart = inhalt.indexOf("## Ziel");
|
||||||
|
issues.push({
|
||||||
|
nummer: Number(kopf[1]),
|
||||||
|
titel: (kopf[2] ?? "").trim(),
|
||||||
|
status: feld(inhalt, "Status") ?? "offen",
|
||||||
|
meilenstein: feld(inhalt, "Meilenstein"),
|
||||||
|
labels: (feld(inhalt, "Labels") ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((label) => label.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
koerper: körperStart >= 0 ? inhalt.slice(körperStart).trim() : inhalt,
|
||||||
|
dateiname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Gitea {
|
||||||
|
constructor(
|
||||||
|
private readonly basis: string,
|
||||||
|
private readonly repo: string,
|
||||||
|
/** Fertiger Wert fuer den Authorization-Header (`token …` oder `Basic …`). */
|
||||||
|
private readonly autorisierung: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async anfrage<T>(pfad: string, optionen: RequestInit = {}): Promise<T> {
|
||||||
|
const antwort = await fetch(`${this.basis}/api/v1/repos/${this.repo}${pfad}`, {
|
||||||
|
...optionen,
|
||||||
|
headers: {
|
||||||
|
Authorization: this.autorisierung,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...optionen.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!antwort.ok) {
|
||||||
|
throw new Error(`Gitea antwortet mit ${antwort.status} auf ${pfad}: ${await antwort.text()}`);
|
||||||
|
}
|
||||||
|
return (await antwort.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
labels() {
|
||||||
|
return this.anfrage<{ id: number; name: string }[]>("/labels?limit=100");
|
||||||
|
}
|
||||||
|
|
||||||
|
labelAnlegen(name: string) {
|
||||||
|
return this.anfrage<{ id: number; name: string }>("/labels", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ name, color: LABEL_FARBEN[name] ?? "#6c757d" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
meilensteine() {
|
||||||
|
return this.anfrage<{ id: number; title: string }[]>("/milestones?state=all&limit=100");
|
||||||
|
}
|
||||||
|
|
||||||
|
meilensteinAnlegen(title: string) {
|
||||||
|
return this.anfrage<{ id: number; title: string }>("/milestones", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
issues() {
|
||||||
|
return this.anfrage<{ number: number; title: string; state: string }[]>("/issues?state=all&limit=200");
|
||||||
|
}
|
||||||
|
|
||||||
|
issueAnlegen(daten: Record<string, unknown>) {
|
||||||
|
return this.anfrage<{ number: number }>("/issues", { method: "POST", body: JSON.stringify(daten) });
|
||||||
|
}
|
||||||
|
|
||||||
|
issueAendern(nummer: number, daten: Record<string, unknown>) {
|
||||||
|
return this.anfrage<{ number: number }>(`/issues/${nummer}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(daten),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt Serveradresse und Repo aus dem Git-Remote, damit beides nicht doppelt gepflegt werden muss.
|
||||||
|
* Beispiel: https://git.example.de/benutzer/sshkv.git → { basis, repo }
|
||||||
|
*/
|
||||||
|
function ausRemote(): { basis: string; repo: string } | null {
|
||||||
|
try {
|
||||||
|
const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim();
|
||||||
|
const treffer = /^https?:\/\/(?:[^@/]*@)?([^/]+)\/(.+?)(?:\.git)?$/.exec(url);
|
||||||
|
if (!treffer) return null;
|
||||||
|
return { basis: `https://${treffer[1]}`, repo: treffer[2] ?? "" };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt die Zugangsdaten aus Git (Schlüsselbund oder `~/.git-credentials`), damit sie nicht
|
||||||
|
* zusätzlich in der .env stehen müssen. Hinterlegt ist dort ein API-Token oder – wie bei
|
||||||
|
* sshkeyvault – das Konto-Passwort; Gitea nimmt beides an, das Passwort aber nur per Basic-Auth
|
||||||
|
* (#030).
|
||||||
|
*/
|
||||||
|
function zugangAusGitCredentials(basis: string): { benutzer: string; geheimnis: string } | null {
|
||||||
|
const host = new URL(basis).host;
|
||||||
|
|
||||||
|
// Erst die eingerichteten Helfer (z. B. Schlüsselbund), dann zusätzlich die Datei
|
||||||
|
// ~/.git-credentials – je nach Rechner ist mal das eine, mal das andere gefüllt.
|
||||||
|
for (const zusatz of [[], ["-c", "credential.helper=store"]]) {
|
||||||
|
try {
|
||||||
|
const antwort = execFileSync("git", [...zusatz, "credential", "fill"], {
|
||||||
|
input: `protocol=https\nhost=${host}\n\n`,
|
||||||
|
encoding: "utf8",
|
||||||
|
// Keine Rückfrage im Terminal: Wenn nichts hinterlegt ist, soll der Aufruf scheitern.
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
||||||
|
stdio: ["pipe", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
const passwort = /^password=(.*)$/m.exec(antwort);
|
||||||
|
const benutzer = /^username=(.*)$/m.exec(antwort);
|
||||||
|
if (passwort?.[1]) return { benutzer: benutzer?.[1] ?? "", geheimnis: passwort[1] };
|
||||||
|
} catch {
|
||||||
|
// Nächste Möglichkeit versuchen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Titel in Gitea, z. B. „#004 Agent-Skript“ – daran werden vorhandene Issues wiedererkannt. */
|
||||||
|
function giteaTitel(issue: IssueDatei): string {
|
||||||
|
return `#${String(issue.nummer).padStart(3, "0")} ${issue.titel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const trockenlauf = process.argv.includes("--dry-run");
|
||||||
|
const remote = ausRemote();
|
||||||
|
const basis = (process.env.GITEA_URL ?? remote?.basis ?? "").replace(/\/$/, "");
|
||||||
|
const repo = process.env.GITEA_REPO ?? remote?.repo ?? "";
|
||||||
|
const zugang = process.env.GITEA_TOKEN ? null : basis ? zugangAusGitCredentials(basis) : null;
|
||||||
|
const token = process.env.GITEA_TOKEN || zugang?.geheimnis || "";
|
||||||
|
const tokenQuelle = process.env.GITEA_TOKEN ? "Umgebungsvariable" : "Git-Zugangsdaten";
|
||||||
|
// Ein Gitea-Token sind 40 Hex-Zeichen; alles andere ist ein Passwort und geht per Basic-Auth.
|
||||||
|
const autorisierung =
|
||||||
|
process.env.GITEA_TOKEN || /^[0-9a-f]{40}$/.test(token) || !zugang?.benutzer
|
||||||
|
? `token ${token}`
|
||||||
|
: `Basic ${Buffer.from(`${zugang.benutzer}:${token}`).toString("base64")}`;
|
||||||
|
|
||||||
|
const verzeichnis = path.join(process.cwd(), "docs", "issues");
|
||||||
|
const issues = await dateienLesen(verzeichnis);
|
||||||
|
console.log(`${issues.length} Issue-Dateien gelesen.`);
|
||||||
|
|
||||||
|
if (trockenlauf || !basis || !repo || !token) {
|
||||||
|
if (!trockenlauf) {
|
||||||
|
console.log(
|
||||||
|
"\nAdresse, Repo oder Token nicht ermittelbar – es läuft nur ein Trockenlauf.\n" +
|
||||||
|
"Entweder GITEA_TOKEN setzen oder einmal `git push` ausführen, damit der Token hinterlegt wird.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const issue of issues) {
|
||||||
|
console.log(
|
||||||
|
` ${giteaTitel(issue)} [${issue.status}] Meilenstein ${issue.meilenstein ?? "–"} Labels: ${
|
||||||
|
issue.labels.join(", ") || "–"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`\nZiel wäre: ${basis || "?"}/${repo || "?"} – Token ${token ? "vorhanden" : "fehlt"}.` +
|
||||||
|
"\nDer Token wird aus den Git-Zugangsdaten gelesen; er braucht zusätzlich das Recht „issue:write“.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Ziel: ${basis}/${repo} (Token aus: ${tokenQuelle})`);
|
||||||
|
const gitea = new Gitea(basis, repo, autorisierung);
|
||||||
|
|
||||||
|
const vorhandeneLabels = new Map((await gitea.labels()).map((l) => [l.name.toLowerCase(), l.id]));
|
||||||
|
const benoetigteLabels = [...new Set(issues.flatMap((issue) => issue.labels))];
|
||||||
|
for (const name of benoetigteLabels) {
|
||||||
|
if (!vorhandeneLabels.has(name)) {
|
||||||
|
const neu = await gitea.labelAnlegen(name);
|
||||||
|
vorhandeneLabels.set(name, neu.id);
|
||||||
|
console.log(`Label angelegt: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhandeneMeilensteine = new Map((await gitea.meilensteine()).map((m) => [m.title, m.id]));
|
||||||
|
const benoetigteMeilensteine = [
|
||||||
|
...new Set(issues.map((issue) => issue.meilenstein).filter((m): m is string => Boolean(m))),
|
||||||
|
].sort();
|
||||||
|
for (const titel of benoetigteMeilensteine) {
|
||||||
|
if (!vorhandeneMeilensteine.has(titel)) {
|
||||||
|
const neu = await gitea.meilensteinAnlegen(titel);
|
||||||
|
vorhandeneMeilensteine.set(titel, neu.id);
|
||||||
|
console.log(`Meilenstein angelegt: ${titel}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhandeneIssues = new Map((await gitea.issues()).map((i) => [i.title, i]));
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
const titel = giteaTitel(issue);
|
||||||
|
const daten = {
|
||||||
|
title: titel,
|
||||||
|
body: `${issue.koerper}\n\n---\nQuelle: \`docs/issues/${issue.dateiname}\` – dort wird dieses Issue gepflegt.`,
|
||||||
|
labels: issue.labels
|
||||||
|
.map((name) => vorhandeneLabels.get(name))
|
||||||
|
.filter((id): id is number => Boolean(id)),
|
||||||
|
milestone: issue.meilenstein ? vorhandeneMeilensteine.get(issue.meilenstein) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const vorhanden = vorhandeneIssues.get(titel);
|
||||||
|
const zustand = issue.status.startsWith("erledigt") ? "closed" : "open";
|
||||||
|
|
||||||
|
if (vorhanden) {
|
||||||
|
await gitea.issueAendern(vorhanden.number, { ...daten, state: zustand });
|
||||||
|
console.log(`Aktualisiert: ${titel} (${zustand})`);
|
||||||
|
} else {
|
||||||
|
const neu = await gitea.issueAnlegen(daten);
|
||||||
|
if (zustand === "closed") await gitea.issueAendern(neu.number, { state: "closed" });
|
||||||
|
console.log(`Angelegt: ${titel} (${zustand})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nAbgleich abgeschlossen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((fehler) => {
|
||||||
|
console.error(fehler instanceof Error ? fehler.message : fehler);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -39,6 +39,20 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
|
|||||||
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
||||||
{ name: "hosts", pfad: "/hosts" },
|
{ name: "hosts", pfad: "/hosts" },
|
||||||
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
||||||
|
// Matrix Schluessel × Host (#025): die Seite darf auf 390 px nicht seitwaerts scrollen –
|
||||||
|
// nur der Matrix-Container. „belegt=1“, damit das Bild Zellen zeigt statt leerer Zeilen.
|
||||||
|
{ name: "zuweisungen", pfad: "/zuweisungen?belegt=1" },
|
||||||
|
{ name: "vorlagen", pfad: "/vorlagen" },
|
||||||
|
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
|
||||||
|
{
|
||||||
|
name: "vorlage-bearbeiten",
|
||||||
|
pfad: "/vorlagen",
|
||||||
|
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
|
||||||
|
},
|
||||||
|
// Audit-Log (#027): unter md Karten statt Tabelle, die Seite darf nicht seitwaerts scrollen.
|
||||||
|
{ name: "protokoll", pfad: "/protokoll" },
|
||||||
|
// Benachrichtigungen, Zeitplaene, Instanz (#028) – als Admin mit Formular und Testknoepfen.
|
||||||
|
{ name: "einstellungen", pfad: "/einstellungen" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ANSICHTEN = [
|
const ANSICHTEN = [
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Bestätigt" };
|
||||||
|
|
||||||
|
/** Ziel der Bestaetigung aus dem Assistenten (#020): der Assistent wartet im anderen Tab. */
|
||||||
|
export default async function BestaetigtSeite() {
|
||||||
|
await sitzungErforderlich();
|
||||||
|
return (
|
||||||
|
<Leerzustand
|
||||||
|
titel="Anmeldung bestätigt"
|
||||||
|
text="Die Bestätigung gilt fünf Minuten. Diesen Tab schließen und im Assistenten „Fortsetzen“ wählen."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Assistent } from "@/components/assistent/Assistent";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { assistentDaten } from "@/server/queries/assistent";
|
||||||
|
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Host-zu-Host-Assistent" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-zu-Host-Assistent (#020): ein Schluesselpaar fuer genau einen Zweck, Private Key auf
|
||||||
|
* Host A, Public Key auf Host B, Verbindungstest. Nur Admins – es wird ein Private Key ausgerollt.
|
||||||
|
*/
|
||||||
|
export default async function AssistentSeite() {
|
||||||
|
const { benutzer } = await sitzungErforderlich("privateKeyAusrollen", "/assistent");
|
||||||
|
const daten = await assistentDaten();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Übersicht", href: "/" }, { titel: "Host-zu-Host-Assistent" }]} />}
|
||||||
|
titel="Host-zu-Host-Assistent"
|
||||||
|
untertitel="Host A soll sich per SSH an Host B anmelden – Schlüssel, Ausrollen und Test in einem Durchgang."
|
||||||
|
/>
|
||||||
|
<Assistent daten={daten} bestaetigt={bestaetigungFrisch(benutzer.bestaetigtAm)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { CheckCheck, KeyRound, ShieldCheck } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { Umstellen } from "@/components/deploymentkey/Umstellen";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||||
|
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||||
|
import { rotationsSchrittAktion } from "@/server/actions/deploymentkey";
|
||||||
|
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { rotationsStand } from "@/server/services/deploymentkey";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Deployment-Key rotieren" };
|
||||||
|
|
||||||
|
const SCHRITTE = ["Erzeugen", "Verteilen", "Prüfen", "Umstellen", "Aufräumen"] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation des Instanz-Deployment-Keys (#031). Der neue Key kommt erst neben den alten,
|
||||||
|
* wird geprueft und erst dann zum Standard; der alte verschwindet zuletzt.
|
||||||
|
*/
|
||||||
|
export default async function DeploymentKeySeite() {
|
||||||
|
const { benutzer } = await sitzungErforderlich("deploymentKeyVerwalten", "/deployment-key");
|
||||||
|
const stand = await rotationsStand();
|
||||||
|
const fehlgeschlagen = (stand.pruefung ?? []).filter((p) => !p.ok);
|
||||||
|
const aktuell =
|
||||||
|
stand.phase === "bereit"
|
||||||
|
? 0
|
||||||
|
: stand.phase === "aufraeumen"
|
||||||
|
? 4
|
||||||
|
: !stand.pruefung
|
||||||
|
? 1
|
||||||
|
: fehlgeschlagen.length
|
||||||
|
? 2
|
||||||
|
: 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb
|
||||||
|
pfad={[{ titel: "Einstellungen", href: "/einstellungen" }, { titel: "Deployment-Key" }]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
titel="Deployment-Key rotieren"
|
||||||
|
untertitel="Ohne Unterbrechung: der neue Key steht erst neben dem alten, wird geprüft und löst ihn dann ab."
|
||||||
|
/>
|
||||||
|
<Schrittleiste schritte={SCHRITTE} aktuell={aktuell} beschriftung="Schritte der Rotation" />
|
||||||
|
|
||||||
|
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
|
||||||
|
<p className="text-accent text-[13.5px]">
|
||||||
|
Jeder Schritt verlangt eine frische Bestätigung Ihrer Anmeldung.{" "}
|
||||||
|
<Link href="/bestaetigen?ziel=%2Fdeployment-key" className="underline">
|
||||||
|
Jetzt bestätigen
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Karte titel="Aktueller Deployment-Key" untertitel={`Benutzt auf ${stand.hosts.length} Host(s)`}>
|
||||||
|
{stand.aktuell ? (
|
||||||
|
<MonoKopieren
|
||||||
|
wert={stand.aktuell.fingerprint}
|
||||||
|
beschriftung="Fingerprint des aktuellen Keys"
|
||||||
|
umbrechen
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-accent text-[14px]">Noch kein Deployment-Key – läuft der Worker?</p>
|
||||||
|
)}
|
||||||
|
{stand.nachfolger && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-muted mb-1 text-[13px]">Neuer Key (noch nicht Standard)</p>
|
||||||
|
<MonoKopieren
|
||||||
|
wert={stand.nachfolger.fingerprint}
|
||||||
|
beschriftung="Fingerprint des neuen Keys"
|
||||||
|
umbrechen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{stand.phase === "bereit" && stand.aktuell && (
|
||||||
|
<Karte
|
||||||
|
titel="1 · Neuen Key erzeugen"
|
||||||
|
untertitel="Der Worker erzeugt ein neues ed25519-Paar. Noch ändert sich auf keinem Host etwas."
|
||||||
|
>
|
||||||
|
<VorgangsKnopf
|
||||||
|
aktion={rotationsSchrittAktion.bind(null, "erzeugen")}
|
||||||
|
beschriftung="Neuen Deployment-Key erzeugen"
|
||||||
|
variante="primaer"
|
||||||
|
icon={<KeyRound className="size-4" />}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stand.phase === "verteilen" && (
|
||||||
|
<>
|
||||||
|
<Karte
|
||||||
|
titel="2 · Neuen Key verteilen"
|
||||||
|
untertitel="Der neue Key kommt zusätzlich in den verwalteten Block – der alte bleibt vorerst."
|
||||||
|
>
|
||||||
|
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={stand.hosts.length} />
|
||||||
|
</Karte>
|
||||||
|
<Karte
|
||||||
|
titel="3 · Anmeldung mit dem neuen Key prüfen"
|
||||||
|
untertitel="sshkv meldet sich auf jedem Host einmal mit dem neuen Key an."
|
||||||
|
>
|
||||||
|
<VorgangsKnopf
|
||||||
|
aktion={rotationsSchrittAktion.bind(null, "pruefen")}
|
||||||
|
beschriftung="Neuen Key prüfen"
|
||||||
|
icon={<ShieldCheck className="size-4" />}
|
||||||
|
/>
|
||||||
|
{stand.pruefung && (
|
||||||
|
<ul className="mt-4 flex flex-col gap-1.5" aria-label="Ergebnis der Prüfung">
|
||||||
|
{stand.pruefung.map((p) => (
|
||||||
|
<li key={p.hostId} className="flex flex-wrap items-center gap-2 text-[14px]">
|
||||||
|
<span className="font-mono">{p.hostName}</span>
|
||||||
|
{p.ok ? (
|
||||||
|
<Badge variante="ok">angemeldet</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variante="gefahr">fehlgeschlagen</Badge>
|
||||||
|
)}
|
||||||
|
{p.fehler && <span className="text-muted text-[13px]">{p.fehler}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
{stand.pruefung && (
|
||||||
|
<Karte
|
||||||
|
titel="4 · Umstellen"
|
||||||
|
untertitel="Ab jetzt benutzt sshkv den neuen Key; der alte gilt als rotiert."
|
||||||
|
>
|
||||||
|
<Umstellen fehlgeschlagen={fehlgeschlagen} />
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stand.phase === "aufraeumen" && stand.vorgaenger && (
|
||||||
|
<Karte
|
||||||
|
titel="5 · Alten Key entfernen"
|
||||||
|
untertitel="Dry-Run und Ausrollen entfernen den alten Key aus allen Blöcken; danach wird sein Private Key verworfen."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<p className="text-muted text-[13px]">
|
||||||
|
Alter Key: <span className="font-mono">{stand.vorgaenger.fingerprint}</span>
|
||||||
|
</p>
|
||||||
|
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={stand.hosts.length} />
|
||||||
|
<div>
|
||||||
|
<VorgangsKnopf
|
||||||
|
aktion={rotationsSchrittAktion.bind(null, "abschliessen")}
|
||||||
|
beschriftung="Rotation abschließen"
|
||||||
|
variante="primaer"
|
||||||
|
icon={<CheckCheck className="size-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,207 @@
|
|||||||
|
import { Inbox } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||||
|
import { datumZeit } from "@/lib/format";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { einstellungenSeite, type Zeitplan, type ZustellungZeile } from "@/server/queries/einstellungen";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Einstellungen" };
|
export const metadata: Metadata = { title: "Einstellungen" };
|
||||||
|
|
||||||
export default async function Einstellungen() {
|
function Info({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite
|
<div className="min-w-0">
|
||||||
titel="Einstellungen"
|
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||||
untertitel="Deployment-Key, eigene Vorlagen, Benachrichtigungen"
|
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
|
||||||
phase={3}
|
</div>
|
||||||
issue="#019"
|
);
|
||||||
/>
|
}
|
||||||
|
|
||||||
|
function Abschnitt({ titel, id, children }: { titel: string; id: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section aria-labelledby={id} className="flex min-w-0 flex-col gap-4">
|
||||||
|
<h2 id={id} className="text-muted text-[12.5px] font-semibold tracking-[0.08em] uppercase">
|
||||||
|
{titel}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZeitplanAnzeige({ plan }: { plan: Zeitplan }) {
|
||||||
|
return (
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
{plan.beschreibung && <span>{plan.beschreibung}</span>}
|
||||||
|
<code className="text-muted font-mono text-[13px]">{plan.ausdruck}</code>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KANAL_TEXT = { EMAIL: "E-Mail", WEBHOOK: "Webhook" } as const;
|
||||||
|
|
||||||
|
function Zustellungen({ zeilen }: { zeilen: readonly ZustellungZeile[] }) {
|
||||||
|
if (zeilen.length === 0) {
|
||||||
|
return (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Inbox className="size-5" />}
|
||||||
|
titel="Noch nichts versendet"
|
||||||
|
text="Hier erscheinen die letzten Zustellungen samt Ergebnis – auch Testnachrichten."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tabelle
|
||||||
|
beschriftung="Letzte Zustellungen"
|
||||||
|
spalten={[{ titel: "Zeitpunkt" }, { titel: "Kanal" }, { titel: "Nachricht" }, { titel: "Ergebnis" }]}
|
||||||
|
>
|
||||||
|
{zeilen.map((z) => (
|
||||||
|
<TabellenZeile key={z.sendung}>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<time dateTime={z.zeitpunkt.toISOString()}>{datumZeit(z.zeitpunkt)}</time>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">{KANAL_TEXT[z.kanal]}</TabellenZelle>
|
||||||
|
<TabellenZelle className="min-w-[16rem]">
|
||||||
|
<span className="block">{z.betreff}</span>
|
||||||
|
<span className="text-muted block text-[12.5px]">
|
||||||
|
{z.anlaesse === 1 ? "1 Anlass" : `${z.anlaesse} Anlässe`}
|
||||||
|
{z.versuche > 1 && ` · ${z.versuche}. Versuch`}
|
||||||
|
</span>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="min-w-[12rem]">
|
||||||
|
{z.ergebnis === "GESENDET" ? (
|
||||||
|
<Badge variante="ok">gesendet</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="flex flex-col items-start gap-1">
|
||||||
|
<Badge variante="gefahr">fehlgeschlagen</Badge>
|
||||||
|
{z.fehler && <span className="text-danger text-[12.5px] break-words">{z.fehler}</span>}
|
||||||
|
<span className="text-muted text-[12.5px]">
|
||||||
|
{z.naechsterVersuchAm
|
||||||
|
? `neuer Versuch ab ${datumZeit(z.naechsterVersuchAm)}`
|
||||||
|
: "kein weiterer Versuch"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einstellungen (#028): lesen duerfen alle, aendern nur `einstellungenVerwalten` (Admin). */
|
||||||
|
export default async function Einstellungen() {
|
||||||
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", "/einstellungen");
|
||||||
|
const darfAendern = darf(benutzer.rolle, "einstellungenVerwalten");
|
||||||
|
const seite = await einstellungenSeite();
|
||||||
|
// Eine Webhook-Adresse traegt oft ein Token im Pfad – Leser sehen nur Schema und Host.
|
||||||
|
const ansicht = darfAendern
|
||||||
|
? seite.benachrichtigung
|
||||||
|
: {
|
||||||
|
...seite.benachrichtigung,
|
||||||
|
webhook: { ...seite.benachrichtigung.webhook, url: seite.webhookZiel ?? "" },
|
||||||
|
};
|
||||||
|
const { instanz } = seite;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Einstellungen"
|
||||||
|
untertitel={
|
||||||
|
darfAendern
|
||||||
|
? "Benachrichtigungen, Zeitpläne und Angaben zu dieser Instanz"
|
||||||
|
: "Nur Ansicht – ändern dürfen Administratoren."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 gap-6 sm:gap-7 xl:grid-cols-[minmax(0,1fr)_22rem] xl:items-start">
|
||||||
|
<Abschnitt titel="Benachrichtigungen" id="abschnitt-benachrichtigungen">
|
||||||
|
<BenachrichtigungFormular ansicht={ansicht} darfAendern={darfAendern} />
|
||||||
|
<Karte
|
||||||
|
titel="Letzte Zustellungen"
|
||||||
|
untertitel="Fehlgeschlagene Zustellungen wiederholt der Worker bis zu fünfmal."
|
||||||
|
>
|
||||||
|
<Zustellungen zeilen={seite.zustellungen} />
|
||||||
|
</Karte>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||||
|
<Abschnitt titel="Abgleich" id="abschnitt-abgleich">
|
||||||
|
<Karte as="div">
|
||||||
|
<dl className="grid gap-4">
|
||||||
|
<Info titel="Abgleich aller Hosts">
|
||||||
|
<ZeitplanAnzeige plan={seite.abgleich} />
|
||||||
|
</Info>
|
||||||
|
<Info titel="Tägliche Zusammenfassung">
|
||||||
|
<ZeitplanAnzeige plan={seite.meldung} />
|
||||||
|
</Info>
|
||||||
|
<Info titel="Geänderte Host-Keys">alle 5 Minuten geprüft</Info>
|
||||||
|
</dl>
|
||||||
|
<p className="text-muted mt-4 text-[13px]">
|
||||||
|
Zeitzone Europe/Berlin. Gesetzt über <code className="font-mono">SSHKV_SYNC_CRON</code> und{" "}
|
||||||
|
<code className="font-mono">SSHKV_MELDUNG_CRON</code> in der Umgebung des Workers.
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
{/* Platz fuer den Abschnitt „Syslog“ – folgt in einem eigenen Issue. */}
|
||||||
|
|
||||||
|
<Abschnitt titel="Instanz" id="abschnitt-instanz">
|
||||||
|
<Karte as="div">
|
||||||
|
<dl className="grid gap-4">
|
||||||
|
<Info titel="Version">
|
||||||
|
<span className="font-mono text-[13.5px]">{instanz.version}</span>
|
||||||
|
</Info>
|
||||||
|
<Info titel="Deployment-Key">
|
||||||
|
{instanz.deploymentKey ? (
|
||||||
|
<>
|
||||||
|
<MonoKopieren
|
||||||
|
wert={instanz.deploymentKey.fingerprint}
|
||||||
|
beschriftung="Fingerprint des Deployment-Keys"
|
||||||
|
umbrechen
|
||||||
|
/>
|
||||||
|
{darf(benutzer.rolle, "deploymentKeyVerwalten") && (
|
||||||
|
<Link
|
||||||
|
href="/deployment-key"
|
||||||
|
className="text-accent mt-1 inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Deployment-Key rotieren
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-accent">noch nicht erzeugt – läuft der Worker?</span>
|
||||||
|
)}
|
||||||
|
</Info>
|
||||||
|
<Info titel="Anmeldung (OIDC)">
|
||||||
|
{instanz.oidc.konfiguriert ? (
|
||||||
|
instanz.oidc.name
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">nicht eingerichtet</span>
|
||||||
|
)}
|
||||||
|
</Info>
|
||||||
|
<Info titel="Notfall-Admin">
|
||||||
|
{!instanz.notfall.vorhanden ? (
|
||||||
|
<span className="text-muted">nicht angelegt</span>
|
||||||
|
) : instanz.notfall.totpEingerichtet ? (
|
||||||
|
<Badge variante="ok">TOTP eingerichtet</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variante="messing">TOTP noch nicht eingerichtet</Badge>
|
||||||
|
)}
|
||||||
|
</Info>
|
||||||
|
</dl>
|
||||||
|
</Karte>
|
||||||
|
</Abschnitt>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
|||||||
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
||||||
import { BootstrapAktionen } from "./BootstrapAktionen";
|
import { BootstrapAktionen } from "./BootstrapAktionen";
|
||||||
import { sshdHinweise } from "@/lib/sshd-hinweise";
|
import { sshdHinweise } from "@/lib/sshd-hinweise";
|
||||||
|
import { AbgleichKarten } from "@/components/abgleich/AbgleichKarten";
|
||||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { istZustand } from "@/server/queries/abgleich";
|
||||||
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
||||||
import { verwendungenAufHost } from "@/server/queries/ziele";
|
import { verwendungenAufHost } from "@/server/queries/ziele";
|
||||||
import { HostKeyAktionen } from "./HostKeyAktionen";
|
import { HostKeyAktionen } from "./HostKeyAktionen";
|
||||||
@@ -100,6 +102,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
|||||||
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
|
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
|
||||||
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
|
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
|
||||||
const verwendungen = await verwendungenAufHost(host.id);
|
const verwendungen = await verwendungenAufHost(host.id);
|
||||||
|
const ist = await istZustand(host.id);
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -295,6 +298,16 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
|||||||
)}
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
|
|
||||||
|
{host.bootstrapAm && (
|
||||||
|
<AbgleichKarten
|
||||||
|
hostId={host.id}
|
||||||
|
zustand={ist}
|
||||||
|
darfAbgleichen={verwalten}
|
||||||
|
darfUebernehmen={darf(benutzer.rolle, "schluesselVerwalten") && !archiviert}
|
||||||
|
darfVerwalten={darfAusrollen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{verwalten && (
|
{verwalten && (
|
||||||
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
|
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { ArrowRight, Plus, RefreshCw, Route } from "lucide-react";
|
import { ArrowRight, Plus, Route } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
|
||||||
abweichungen,
|
|
||||||
BEISPIEL_JETZT,
|
|
||||||
baldAblaufend,
|
|
||||||
kennzahlen,
|
|
||||||
letzteAktionen,
|
|
||||||
letzterAbgleich,
|
|
||||||
type AktionsArt,
|
|
||||||
} from "@/beispiel/uebersicht";
|
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Karte } from "@/components/ui/Karte";
|
import { Karte } from "@/components/ui/Karte";
|
||||||
@@ -18,7 +10,10 @@ import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
|||||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||||
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
|
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { uebersicht, type AktionsArt } from "@/server/queries/uebersicht";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Übersicht" };
|
export const metadata: Metadata = { title: "Übersicht" };
|
||||||
|
|
||||||
@@ -28,36 +23,45 @@ const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
|
|||||||
erstellt: { text: "Erstellt", farbe: "text-text" },
|
erstellt: { text: "Erstellt", farbe: "text-text" },
|
||||||
abgleich: { text: "Abgleich", farbe: "text-info" },
|
abgleich: { text: "Abgleich", farbe: "text-info" },
|
||||||
rotiert: { text: "Rotiert", farbe: "text-accent" },
|
rotiert: { text: "Rotiert", farbe: "text-accent" },
|
||||||
|
anmeldung: { text: "Anmeldung", farbe: "text-muted" },
|
||||||
|
verweigert: { text: "Verweigert", farbe: "text-danger" },
|
||||||
|
sonstiges: { text: "Aktion", farbe: "text-text" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Übersicht (#026): Aufbau aus dem Prototyp (#002), Zahlen aus der Datenbank. */
|
||||||
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
|
|
||||||
* folgt in #026; Aufbau und Komponenten bleiben.
|
|
||||||
*/
|
|
||||||
export default async function Uebersicht() {
|
export default async function Uebersicht() {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
await sitzungErforderlich();
|
const { benutzer } = await sitzungErforderlich();
|
||||||
|
const jetzt = new Date();
|
||||||
|
const { letzterAbgleich, kennzahlen, mitAbweichung, weitereAbweichungen, baldAblaufend, letzteAktionen } =
|
||||||
|
await uebersicht(jetzt);
|
||||||
|
const verwalten = darf(benutzer.rolle, "hostsVerwalten");
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 sm:gap-7">
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
<Seitenkopf
|
<Seitenkopf
|
||||||
titel="Übersicht"
|
titel="Übersicht"
|
||||||
badges={<Badge variante="messing">Prototyp · Beispieldaten</Badge>}
|
|
||||||
untertitel={
|
untertitel={
|
||||||
<>
|
letzterAbgleich ? (
|
||||||
Letzter Abgleich {relativ(letzterAbgleich, BEISPIEL_JETZT)} ·{" "}
|
<>
|
||||||
<time dateTime={letzterAbgleich} className="font-mono text-[13px]">
|
Letzter Abgleich {relativ(letzterAbgleich, jetzt)} ·{" "}
|
||||||
{datumZeit(letzterAbgleich)}
|
<time dateTime={letzterAbgleich.toISOString()} className="font-mono text-[13px]">
|
||||||
</time>
|
{datumZeit(letzterAbgleich)}
|
||||||
</>
|
</time>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Noch kein Abgleich gelaufen."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
aktionen={
|
aktionen={
|
||||||
<>
|
<>
|
||||||
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
{verwalten && <AlleAbgleichenKnopf />}
|
||||||
<Button href="/zuweisungen" icon={<Route className="size-4" />}>
|
{darf(benutzer.rolle, "privateKeyAusrollen") && (
|
||||||
Host-zu-Host-Assistent
|
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||||
</Button>
|
Host-zu-Host-Assistent
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
href="/schluessel"
|
href="/schluessel/neu"
|
||||||
variante="primaer"
|
variante="primaer"
|
||||||
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||||
>
|
>
|
||||||
@@ -97,46 +101,60 @@ export default async function Uebersicht() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
|
||||||
<Karte titel="Hosts mit Abweichung" untertitel="Ergebnis des letzten Abgleichs">
|
<Karte
|
||||||
<Tabelle
|
titel="Hosts mit Abweichung"
|
||||||
beschriftung="Hosts mit Abweichung"
|
untertitel={
|
||||||
spalten={[
|
weitereAbweichungen
|
||||||
{ titel: "Host" },
|
? `Ergebnis des letzten Abgleichs · ${weitereAbweichungen} weitere`
|
||||||
{ titel: "Benutzer" },
|
: "Ergebnis des letzten Abgleichs"
|
||||||
{ titel: "Befund" },
|
}
|
||||||
{ titel: "Aktion", rechts: true, versteckt: true },
|
>
|
||||||
]}
|
{mitAbweichung.length === 0 ? (
|
||||||
>
|
<p className="text-ok text-[14px]">● Alle Hosts synchron – keine offenen Abweichungen.</p>
|
||||||
{abweichungen.map((a) => (
|
) : (
|
||||||
<TabellenZeile key={`${a.host}-${a.benutzer}`}>
|
<Tabelle
|
||||||
<TabellenZelle mono className="whitespace-nowrap">
|
beschriftung="Hosts mit Abweichung"
|
||||||
{a.host}
|
spalten={[
|
||||||
</TabellenZelle>
|
{ titel: "Host" },
|
||||||
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
|
{ titel: "Benutzer" },
|
||||||
<TabellenZelle className="whitespace-nowrap">
|
{ titel: "Befund" },
|
||||||
<Statuspunkt status={a.status} text={a.befund} />
|
{ titel: "Aktion", rechts: true, versteckt: true },
|
||||||
</TabellenZelle>
|
]}
|
||||||
<TabellenZelle rechts>
|
>
|
||||||
<Link
|
{mitAbweichung.map((a) => (
|
||||||
href="/hosts"
|
<TabellenZeile key={`${a.hostId}-${a.benutzer}-${a.status}`}>
|
||||||
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
|
<TabellenZelle mono className="whitespace-nowrap">
|
||||||
>
|
{a.host}
|
||||||
Prüfen<span className="sr-only"> – {a.host}</span>
|
</TabellenZelle>
|
||||||
</Link>
|
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
|
||||||
</TabellenZelle>
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
</TabellenZeile>
|
<Statuspunkt status={a.status} text={a.befund} />
|
||||||
))}
|
</TabellenZelle>
|
||||||
</Tabelle>
|
<TabellenZelle rechts>
|
||||||
|
<Link
|
||||||
|
href={`/hosts/${a.hostId}`}
|
||||||
|
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Prüfen<span className="sr-only"> – {a.host}</span>
|
||||||
|
</Link>
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
|
|
||||||
<Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen">
|
<Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen">
|
||||||
|
{baldAblaufend.length === 0 && (
|
||||||
|
<p className="text-muted text-[14px]">Kein Schlüssel läuft in den nächsten 30 Tagen ab.</p>
|
||||||
|
)}
|
||||||
<ul className="flex flex-col">
|
<ul className="flex flex-col">
|
||||||
{baldAblaufend.map((s) => {
|
{baldAblaufend.map((s) => {
|
||||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
const tage = tageBis(s.ablauf, jetzt);
|
||||||
return (
|
return (
|
||||||
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
<li key={s.id} className="border-border border-b last:border-b-0">
|
||||||
<Link
|
<Link
|
||||||
href="/schluessel"
|
href={`/schluessel/${s.id}`}
|
||||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
@@ -145,8 +163,16 @@ export default async function Uebersicht() {
|
|||||||
{s.person} · <span className="font-mono">{s.typ}</span>
|
{s.person} · <span className="font-mono">{s.typ}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Badge variante="messing" className="shrink-0">
|
<Badge variante={tage < 0 ? "gefahr" : "messing"} className="shrink-0">
|
||||||
<time dateTime={s.ablauf}>{tage === 1 ? "morgen" : `in ${tage} Tagen`}</time>
|
<time dateTime={s.ablauf.toISOString()}>
|
||||||
|
{tage < 0
|
||||||
|
? `abgelaufen${s.ausgerollt ? " · noch ausgerollt" : ""}`
|
||||||
|
: tage === 0
|
||||||
|
? "heute"
|
||||||
|
: tage === 1
|
||||||
|
? "morgen"
|
||||||
|
: `in ${tage} Tagen`}
|
||||||
|
</time>
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -167,13 +193,17 @@ export default async function Uebersicht() {
|
|||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{letzteAktionen.length === 0 && <Leerzustand titel="Noch keine Aktionen" />}
|
||||||
<ol className="flex flex-col">
|
<ol className="flex flex-col">
|
||||||
{letzteAktionen.map((a) => (
|
{letzteAktionen.map((a) => (
|
||||||
<li
|
<li
|
||||||
key={a.zeit}
|
key={a.id}
|
||||||
className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline"
|
className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline"
|
||||||
>
|
>
|
||||||
<time dateTime={a.zeit} className="text-muted font-mono text-[12.5px] whitespace-nowrap">
|
<time
|
||||||
|
dateTime={a.zeit.toISOString()}
|
||||||
|
className="text-muted font-mono text-[12.5px] whitespace-nowrap"
|
||||||
|
>
|
||||||
{datum(a.zeit)} {uhrzeit(a.zeit)}
|
{datum(a.zeit)} {uhrzeit(a.zeit)}
|
||||||
</time>
|
</time>
|
||||||
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>
|
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>
|
||||||
|
|||||||
@@ -1,18 +1,202 @@
|
|||||||
|
import { Download, ScrollText, Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import { KettenPruefung } from "@/components/protokoll/KettenPruefung";
|
||||||
|
import { ProtokollListe } from "@/components/protokoll/ProtokollListe";
|
||||||
|
import { Button, buttonKlassen } from "@/components/ui/Button";
|
||||||
|
import { Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { AKTIONSGRUPPEN } from "@/lib/protokoll-aktionen";
|
||||||
|
import {
|
||||||
|
cursorAus,
|
||||||
|
ERGEBNIS_TEXT,
|
||||||
|
protokollFilterAktiv,
|
||||||
|
protokollFilterAus,
|
||||||
|
protokollFilterQuery,
|
||||||
|
} from "@/lib/protokoll-filter";
|
||||||
|
import { syslogZielText } from "@/lib/syslog";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { protokollAkteure, protokollSeite, protokollZaehlen } from "@/server/queries/protokoll";
|
||||||
|
import { ketteZwischengespeichert, syslogKonfig } from "@/server/services/protokoll";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Audit-Log" };
|
export const metadata: Metadata = { title: "Audit-Log" };
|
||||||
|
|
||||||
export default async function Protokoll() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-Log (#027): jede Rolle darf lesen. Filter per GET, damit Links, „ältere laden“ und
|
||||||
|
* der CSV-Export denselben Ausschnitt meinen. Es gibt hier bewusst keinen Knopf, der einen
|
||||||
|
* Eintrag aendert oder loescht.
|
||||||
|
*/
|
||||||
|
export default async function Protokoll({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
await sitzungErforderlich();
|
await sitzungErforderlich("lesen", "/protokoll");
|
||||||
|
const parameter = await searchParams;
|
||||||
|
const filter = protokollFilterAus(parameter);
|
||||||
|
const vor = cursorAus(parameter["vor"]);
|
||||||
|
const [seite, zaehler, akteure] = await Promise.all([
|
||||||
|
protokollSeite(filter, vor),
|
||||||
|
protokollZaehlen(filter),
|
||||||
|
protokollAkteure(),
|
||||||
|
]);
|
||||||
|
const gefiltert = protokollFilterAktiv(filter);
|
||||||
|
const query = protokollFilterQuery(filter);
|
||||||
|
const kette = ketteZwischengespeichert();
|
||||||
|
const syslog = syslogKonfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite
|
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||||
titel="Audit-Log"
|
<Seitenkopf
|
||||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
|
titel="Audit-Log"
|
||||||
phase={5}
|
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen – nur lesbar, nicht änderbar"
|
||||||
issue="#027"
|
aktionen={
|
||||||
/>
|
<a href={`/api/protokoll.csv${query}`} download className={buttonKlassen("sekundaer")}>
|
||||||
|
<Download className="size-4" aria-hidden />
|
||||||
|
CSV exportieren
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte innen="ohne" className="divide-border divide-y">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 px-5 py-4 sm:px-6">
|
||||||
|
<p className="text-[14px]">
|
||||||
|
<span className="font-display text-text text-[22px] font-semibold tabular-nums">
|
||||||
|
{zaehler.gesamt.toLocaleString("de-DE")}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-muted">Einträge insgesamt</span>
|
||||||
|
</p>
|
||||||
|
{gefiltert && (
|
||||||
|
<p className="text-muted text-[14px]">
|
||||||
|
<span className="text-text font-medium tabular-nums">
|
||||||
|
{zaehler.treffer.toLocaleString("de-DE")}
|
||||||
|
</span>{" "}
|
||||||
|
passen zum Filter
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-muted text-[13px] sm:ml-auto">
|
||||||
|
Syslog:{" "}
|
||||||
|
{syslog ? (
|
||||||
|
<span className="text-text font-mono">{syslogZielText(syslog)}</span>
|
||||||
|
) : (
|
||||||
|
<span>nicht eingerichtet</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 sm:px-6">
|
||||||
|
<KettenPruefung
|
||||||
|
anfang={
|
||||||
|
kette
|
||||||
|
? {
|
||||||
|
fehlerhafteId: kette.fehlerhafteId,
|
||||||
|
eintraege: kette.eintraege,
|
||||||
|
geprueftAm: kette.geprueftAm.toISOString(),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<form method="get" role="search" className="grid grid-cols-2 items-end gap-3 lg:grid-cols-4">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={filter.q}
|
||||||
|
placeholder="Fingerprint, Host oder Benutzer"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="col-span-2"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Host"
|
||||||
|
name="host"
|
||||||
|
defaultValue={filter.host}
|
||||||
|
placeholder="z. B. srv-pve01"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
mono
|
||||||
|
className="col-span-2 sm:col-span-1"
|
||||||
|
/>
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Akteur"
|
||||||
|
name="akteur"
|
||||||
|
defaultValue={filter.akteur}
|
||||||
|
className="col-span-2 sm:col-span-1"
|
||||||
|
>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{/* Ein gefilterter Name, der nicht mehr unter den haeufigsten ist, bleibt waehlbar. */}
|
||||||
|
{[...new Set([...akteure, ...(filter.akteur ? [filter.akteur] : [])])].map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Aktion" name="gruppe" defaultValue={filter.gruppe}>
|
||||||
|
<option value="">Alle Aktionen</option>
|
||||||
|
{Object.entries(AKTIONSGRUPPEN).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Ergebnis" name="ergebnis" defaultValue={filter.ergebnis}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{Object.entries(ERGEBNIS_TEXT).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Feld beschriftung="Von" name="von" type="date" defaultValue={filter.von} />
|
||||||
|
<Feld beschriftung="Bis" name="bis" type="date" defaultValue={filter.bis} />
|
||||||
|
<div className="col-span-2 flex gap-2.5 sm:justify-end lg:col-span-4">
|
||||||
|
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||||
|
Filtern
|
||||||
|
</Button>
|
||||||
|
{gefiltert && (
|
||||||
|
<Button href="/protokoll" className="flex-1 sm:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{vor !== undefined && (
|
||||||
|
<p className="text-muted text-[14px]">
|
||||||
|
Einträge älter als #{String(vor)}.{" "}
|
||||||
|
<a href={`/protokoll${query}`} className="text-accent underline-offset-2 hover:underline">
|
||||||
|
Zu den neuesten
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{seite.zeilen.length === 0 ? (
|
||||||
|
gefiltert ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Search className="size-5" />}
|
||||||
|
titel="Keine Treffer"
|
||||||
|
text="Kein Eintrag passt zu Suche und Filter."
|
||||||
|
aktion={<Button href="/protokoll">Filter zurücksetzen</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<ScrollText className="size-5" />}
|
||||||
|
titel="Noch keine Einträge"
|
||||||
|
text="Sobald jemand sich anmeldet oder etwas ändert, steht es hier."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Karte>
|
||||||
|
{/* key: neue Filter oder ein neuer Cursor beginnen eine frische Liste. */}
|
||||||
|
<ProtokollListe
|
||||||
|
key={`${query}|${String(vor ?? "")}`}
|
||||||
|
seite={seite}
|
||||||
|
query={query.replace(/^\?/, "")}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Download, Lock, LockOpen, Pencil } from "lucide-react";
|
import { Download, Lock, LockOpen, Pencil, RefreshCw } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
@@ -15,10 +15,16 @@ import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
|
|||||||
import { datum, datumZeit } from "@/lib/format";
|
import { datum, datumZeit } from "@/lib/format";
|
||||||
import { darf } from "@/server/auth/rechte";
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
import { rotationsKette, schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||||
import { zielAuswahl } from "@/server/queries/ziele";
|
import { zielAuswahl } from "@/server/queries/ziele";
|
||||||
|
import { widerrufUebersicht } from "@/server/queries/widerruf";
|
||||||
|
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||||
|
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
|
||||||
|
import { ablaufZustand } from "@/lib/schluessel";
|
||||||
|
import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen";
|
||||||
|
import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation";
|
||||||
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||||
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||||
@@ -187,18 +193,75 @@ function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
|
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
|
||||||
export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) {
|
export default async function SchluesselSeite({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ zielkonten?: string | string[] }>;
|
||||||
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
|
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
|
||||||
const s = await schluesselDetail(id);
|
const s = await schluesselDetail(id);
|
||||||
if (!s) notFound();
|
if (!s) notFound();
|
||||||
|
const zielkontenParameter = (await searchParams).zielkonten;
|
||||||
|
|
||||||
const aktiv = s.status === "AKTIV";
|
// Im Widerruf (#024) bleibt der Status AKTIV, bis alles entfernt ist – zuweisen geht aber nicht mehr.
|
||||||
|
const imWiderruf = Boolean(s.widerrufEingeleitetAm) && (s.status === "AKTIV" || s.status === "ABGELAUFEN");
|
||||||
|
const aktiv = s.status === "AKTIV" && !imWiderruf;
|
||||||
const rechte: Rechte = {
|
const rechte: Rechte = {
|
||||||
public: darf(benutzer.rolle, "publicKeyAusrollen"),
|
public: darf(benutzer.rolle, "publicKeyAusrollen"),
|
||||||
privat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
privat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
testen: darf(benutzer.rolle, "hostsVerwalten"),
|
testen: darf(benutzer.rolle, "hostsVerwalten"),
|
||||||
};
|
};
|
||||||
|
const jetzt = new Date();
|
||||||
|
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
|
||||||
|
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
|
||||||
|
const rotationDesVorgaengers =
|
||||||
|
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
|
||||||
|
? s.vorgaenger
|
||||||
|
: null;
|
||||||
|
const widerrufbar =
|
||||||
|
!imWiderruf &&
|
||||||
|
!(s.nachfolger?.status === "AKTIV") &&
|
||||||
|
// Ein Nachfolger mitten in der Rotation wird ueber „Rotation abbrechen“ widerrufen (#032) –
|
||||||
|
// so steht der Abbruch im Protokoll und die Rotationsseite kennt den Stand.
|
||||||
|
!rotationDesVorgaengers &&
|
||||||
|
s.kategorie !== "DEPLOYMENT" &&
|
||||||
|
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
|
||||||
|
rechte.public &&
|
||||||
|
(s.privateVerwendungen.length === 0 || rechte.privat);
|
||||||
|
const widerruf = widerrufbar || imWiderruf ? await widerrufUebersicht([s.id]) : undefined;
|
||||||
|
// Rotation (#023): Kette, Knopf bzw. Hinweis fuer Schluessel ohne Private Key in sshkv.
|
||||||
|
const kette = await rotationsKette(s.id);
|
||||||
|
const aktivePrivat = s.privateVerwendungen.filter((v) => v.zustand !== "ENTFERNEN").length;
|
||||||
|
const rotationsSperre = rotationGesperrt({
|
||||||
|
kategorie: s.kategorie,
|
||||||
|
status: s.status,
|
||||||
|
masterKeyVersion: s.privatGespeichert ? 1 : null,
|
||||||
|
widerrufEingeleitetAm: s.widerrufEingeleitetAm,
|
||||||
|
privatZuweisungen: aktivePrivat,
|
||||||
|
});
|
||||||
|
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
|
||||||
|
const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
|
||||||
|
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
|
||||||
|
// Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter
|
||||||
|
// passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr.
|
||||||
|
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger));
|
||||||
|
// Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
|
||||||
|
const darfAbbrechen =
|
||||||
|
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
|
||||||
|
!imWiderruf &&
|
||||||
|
s.status === "AKTIV" &&
|
||||||
|
verwaltenRecht &&
|
||||||
|
rechte.public &&
|
||||||
|
(s.privateVerwendungen.length === 0 || rechte.privat);
|
||||||
|
const vorauswahl = (
|
||||||
|
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
|
||||||
|
)
|
||||||
|
.split(",")
|
||||||
|
.filter(Boolean);
|
||||||
const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined;
|
const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined;
|
||||||
const personenKey = s.kategorie === "PERSON";
|
const personenKey = s.kategorie === "PERSON";
|
||||||
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
@@ -206,7 +269,6 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
|
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
|
||||||
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
|
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
|
||||||
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
|
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
|
||||||
const jetzt = new Date();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 sm:gap-7">
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
@@ -228,6 +290,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
<KategorieBadge kategorie={s.kategorie} />
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
<StatusBadge status={s.status} />
|
<StatusBadge status={s.status} />
|
||||||
|
{imWiderruf && <Badge variante="gefahr">Widerruf unvollständig</Badge>}
|
||||||
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
|
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
|
||||||
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
||||||
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
|
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
|
||||||
@@ -240,14 +303,121 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
aktionen={
|
aktionen={
|
||||||
verwalten && (
|
(verwalten || rotierbar || (widerrufbar && widerruf)) && (
|
||||||
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
<>
|
||||||
Bearbeiten
|
{verwalten && (
|
||||||
</Button>
|
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{rotierbar && (
|
||||||
|
<Button href={`/schluessel/${s.id}/rotieren`} icon={<RefreshCw className="size-4" />}>
|
||||||
|
{rotationLaeuft ? "Rotation fortsetzen" : "Rotieren"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{widerrufbar && widerruf && (
|
||||||
|
<WiderrufKnopf uebersicht={widerruf} ruecksprung={`/schluessel/${s.id}`} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{rotationDesVorgaengers &&
|
||||||
|
(imWiderruf || s.status === "WIDERRUFEN" ? (
|
||||||
|
<Karte titel="Rotation abgebrochen">
|
||||||
|
<p className="text-muted text-[14px]">
|
||||||
|
Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen.
|
||||||
|
{imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "}
|
||||||
|
<Link href={`/schluessel/${rotationDesVorgaengers.id}`} className="text-accent hover:underline">
|
||||||
|
{rotationDesVorgaengers.name}
|
||||||
|
</Link>{" "}
|
||||||
|
bleibt aktiv.
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
) : (
|
||||||
|
<Karte titel="Rotation läuft" className="border-accent">
|
||||||
|
<p className="text-muted text-[14px]">
|
||||||
|
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
|
||||||
|
neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "}
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${rotationDesVorgaengers.id}/rotieren`}
|
||||||
|
className="text-accent hover:underline"
|
||||||
|
>
|
||||||
|
Rotation fortsetzen
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{darfAbbrechen && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<RotationAbbrechen
|
||||||
|
altId={rotationDesVorgaengers.id}
|
||||||
|
altName={rotationDesVorgaengers.name}
|
||||||
|
neuName={s.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{imWiderruf && widerruf && (
|
||||||
|
<Karte
|
||||||
|
titel="Widerruf läuft"
|
||||||
|
untertitel="Der Schlüssel gilt erst als widerrufen, wenn er von allen Hosts entfernt ist."
|
||||||
|
className="border-danger-border"
|
||||||
|
>
|
||||||
|
<WiderrufAblauf
|
||||||
|
schluesselIds={[s.id]}
|
||||||
|
offen={widerruf.offen}
|
||||||
|
zielkonten={widerruf.zielkonten}
|
||||||
|
vorauswahl={vorauswahl}
|
||||||
|
ruecksprung={`/schluessel/${s.id}`}
|
||||||
|
darfPrivat={rechte.privat}
|
||||||
|
ohneZielkonten={rotationLaeuft || Boolean(rotationDesVorgaengers)}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{abgelaufen && widerrufbar && widerruf && (
|
||||||
|
<Karte titel="Abgelaufen" className="border-danger-border">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-muted max-w-xl text-[14px]">
|
||||||
|
{s.ablaufAm ? (
|
||||||
|
<>
|
||||||
|
Das Ablaufdatum (<time dateTime={s.ablaufAm.toISOString()}>{datum(s.ablaufAm)}</time>) ist
|
||||||
|
überschritten.{" "}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
Nach Projektende bzw. Ablaufdatum: Widerruf mit einem Klick. sshkv entfernt abgelaufene
|
||||||
|
Schlüssel nie von selbst – erst nach Ihrer Bestätigung und dem Dry-Run.
|
||||||
|
</p>
|
||||||
|
<WiderrufKnopf
|
||||||
|
uebersicht={widerruf}
|
||||||
|
ruecksprung={`/schluessel/${s.id}`}
|
||||||
|
beschriftung="Jetzt widerrufen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(s.status === "WIDERRUFEN" || s.status === "ROTIERT") && (
|
||||||
|
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
|
||||||
|
<p className="text-muted text-[14px]">
|
||||||
|
Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen.
|
||||||
|
{/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */}
|
||||||
|
{s.nachfolger && s.status === "ROTIERT" && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
Ersetzt durch{" "}
|
||||||
|
<Link href={`/schluessel/${s.nachfolger.id}`} className="text-accent hover:underline">
|
||||||
|
{s.nachfolger.name}
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
<Karte as="div" innen="ohne">
|
<Karte as="div" innen="ohne">
|
||||||
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
||||||
<InfoFeld titel="Fingerprint">
|
<InfoFeld titel="Fingerprint">
|
||||||
@@ -321,8 +491,13 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rechte.public &&
|
{rechte.public &&
|
||||||
|
!imWiderruf &&
|
||||||
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
|
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
|
||||||
<Karte
|
<Karte
|
||||||
|
// Sprungziel fuer „Zum Dry-Run“ aus der Zuweisungsmatrix; scroll-mt haelt die Karte
|
||||||
|
// unter der festen Kopfleiste der schmalen Ansicht (#032).
|
||||||
|
id="dry-run"
|
||||||
|
className="scroll-mt-20"
|
||||||
titel="Dry-Run-Vorschau"
|
titel="Dry-Run-Vorschau"
|
||||||
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
|
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
|
||||||
>
|
>
|
||||||
@@ -336,6 +511,50 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
</Karte>
|
</Karte>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(kette.length > 1 || (rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf)) && (
|
||||||
|
<Karte
|
||||||
|
titel="Rotation"
|
||||||
|
untertitel={kette.length > 1 ? "Vorgänger und Nachfolger dieses Schlüssels" : undefined}
|
||||||
|
>
|
||||||
|
{kette.length > 1 && (
|
||||||
|
<ol className="flex flex-wrap items-center gap-2 text-[14px]" aria-label="Rotationskette">
|
||||||
|
{kette.map((glied, index) => (
|
||||||
|
<li key={glied.id} className="flex items-center gap-2">
|
||||||
|
{index > 0 && (
|
||||||
|
<span className="text-muted" aria-hidden>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{glied.id === s.id ? (
|
||||||
|
<span className="font-medium" aria-current="true">
|
||||||
|
{glied.name}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link href={`/schluessel/${glied.id}`} className="text-accent hover:underline">
|
||||||
|
{glied.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<StatusBadge status={glied.status} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{abgebrocheneRotation && s.nachfolger && (
|
||||||
|
<p className="text-muted mt-3 text-[14px]">
|
||||||
|
Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen
|
||||||
|
{s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"};
|
||||||
|
dieser Schlüssel bleibt aktiv. Ein zweiter Nachfolger ist nicht vorgesehen: für eine neue
|
||||||
|
Rotation einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
|
||||||
|
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
|
||||||
|
{rotationsSperre.grund}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
<Karte
|
<Karte
|
||||||
titel="Public Key"
|
titel="Public Key"
|
||||||
untertitel={
|
untertitel={
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Rotation } from "@/components/rotation/Rotation";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { RotationsFehler, rotationsStand } from "@/server/services/rotation";
|
||||||
|
import { offeneEntfernungen } from "@/server/services/widerruf";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Schlüssel rotieren" };
|
||||||
|
|
||||||
|
/** Gefuehrte Rotation (#023): Schrittfolge, wiederaufnehmbar. */
|
||||||
|
export default async function RotierenSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { benutzer } = await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/rotieren`);
|
||||||
|
if (!darf(benutzer.rolle, "publicKeyAusrollen")) notFound();
|
||||||
|
let stand;
|
||||||
|
try {
|
||||||
|
stand = await rotationsStand(id);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RotationsFehler) notFound();
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const { alt } = stand;
|
||||||
|
|
||||||
|
const kopf = (
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb
|
||||||
|
pfad={[
|
||||||
|
{ titel: "Schlüssel", href: "/schluessel" },
|
||||||
|
{ titel: alt.name, href: `/schluessel/${alt.id}` },
|
||||||
|
{ titel: "Rotieren" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
titel={`„${alt.name}“ rotieren`}
|
||||||
|
untertitel="Neuer Schlüssel an allen Stellen, testen, erst dann den alten entfernen."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ohne Nachfolger und nicht rotierbar: nur der Hinweis, keine halbe Rotation.
|
||||||
|
if (!stand.neu && stand.gesperrt) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
{kopf}
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
titel={stand.gesperrt.neuerPublicKeyNoetig ? "Neuer Public Key nötig" : "Nicht rotierbar"}
|
||||||
|
text={stand.gesperrt.grund}
|
||||||
|
aktion={<Button href={`/schluessel/${alt.id}`}>Zurück zum Schlüssel</Button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const widerruf =
|
||||||
|
stand.neu && !stand.abgebrochen && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
|
||||||
|
? await offeneEntfernungen([alt.id])
|
||||||
|
: null;
|
||||||
|
// Abbruch (#032): solange der neue Schluessel nicht widerrufen ist, laeuft sein Widerruf hier weiter.
|
||||||
|
const abbruch =
|
||||||
|
stand.neu && stand.abgebrochen
|
||||||
|
? { offen: stand.neu.status === "WIDERRUFEN" ? null : await offeneEntfernungen([stand.neu.id]) }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
{kopf}
|
||||||
|
<Rotation
|
||||||
|
daten={{
|
||||||
|
alt: { id: alt.id, name: alt.name },
|
||||||
|
neu: stand.neu ? { id: stand.neu.id, name: stand.neu.name } : null,
|
||||||
|
schritt: stand.schritt,
|
||||||
|
neuOffen: stand.neu ? stand.neuOffen : 0,
|
||||||
|
tests: stand.neu ? stand.tests : [],
|
||||||
|
widerruf,
|
||||||
|
abbruch,
|
||||||
|
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { KeyRound, Lock, Plus, Search } from "lucide-react";
|
import { Ban, KeyRound, Lock, Plus, Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { AlleAuswaehlen } from "@/components/widerruf/AlleAuswaehlen";
|
||||||
import { Auswahlliste, Feld } from "@/components/ui/Feld";
|
import { Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||||
import { Karte } from "@/components/ui/Karte";
|
import { Karte } from "@/components/ui/Karte";
|
||||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
@@ -50,6 +52,31 @@ function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MASSEN_FORMULAR = "massenwiderruf";
|
||||||
|
|
||||||
|
/** Nur Schluessel, die sich widerrufen lassen, sind waehlbar (#024). */
|
||||||
|
function Auswahl({ s }: { s: SchluesselListenEintrag }) {
|
||||||
|
const gesperrt = s.kategorie === "DEPLOYMENT" || s.status === "WIDERRUFEN" || s.status === "ROTIERT";
|
||||||
|
return (
|
||||||
|
<label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="id"
|
||||||
|
value={s.id}
|
||||||
|
form={MASSEN_FORMULAR}
|
||||||
|
disabled={gesperrt}
|
||||||
|
aria-label={`${s.name} für den Massenwiderruf auswählen`}
|
||||||
|
className="accent-accent size-[18px] cursor-pointer disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WiderrufBadge({ s }: { s: SchluesselListenEintrag }) {
|
||||||
|
if (!s.widerrufEingeleitetAm || (s.status !== "AKTIV" && s.status !== "ABGELAUFEN")) return null;
|
||||||
|
return <Badge variante="gefahr">Widerruf unvollständig</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */
|
/** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */
|
||||||
export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) {
|
export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
@@ -60,6 +87,8 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
|||||||
const status = einzeln(parameter["status"]);
|
const status = einzeln(parameter["status"]);
|
||||||
const liste = await schluesselListe({ q, kategorie, status });
|
const liste = await schluesselListe({ q, kategorie, status });
|
||||||
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
// Massenwiderruf (#024): Auswahl nur fuer Admins; die frische Bestaetigung prueft die Aktion.
|
||||||
|
const massen = darf(benutzer.rolle, "massenwiderruf");
|
||||||
const gefiltert = q !== "" || kategorie !== "" || status !== "";
|
const gefiltert = q !== "" || kategorie !== "" || status !== "";
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
|
|
||||||
@@ -142,13 +171,27 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Karte>
|
<Karte>
|
||||||
|
{massen && (
|
||||||
|
<form
|
||||||
|
id={MASSEN_FORMULAR}
|
||||||
|
method="get"
|
||||||
|
action="/schluessel/widerruf"
|
||||||
|
className="border-border mb-3 flex flex-wrap items-center justify-between gap-2 border-b pb-3"
|
||||||
|
>
|
||||||
|
<AlleAuswaehlen formular={MASSEN_FORMULAR} />
|
||||||
|
<Button type="submit" variante="gefahr" klein icon={<Ban className="size-4" />}>
|
||||||
|
Ausgewählte widerrufen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
{/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */}
|
{/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */}
|
||||||
<ul className="flex flex-col md:hidden">
|
<ul className="flex flex-col md:hidden">
|
||||||
{liste.map((s) => (
|
{liste.map((s) => (
|
||||||
<li key={s.id} className="border-border border-b last:border-b-0">
|
<li key={s.id} className="border-border flex items-start gap-1 border-b last:border-b-0">
|
||||||
|
{massen && <Auswahl s={s} />}
|
||||||
<Link
|
<Link
|
||||||
href={`/schluessel/${s.id}`}
|
href={`/schluessel/${s.id}`}
|
||||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-2 px-2 py-3 transition-colors"
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 min-w-0 flex-1 flex-col gap-2 px-2 py-3 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
|
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
|
||||||
<span className="truncate">{s.name}</span>
|
<span className="truncate">{s.name}</span>
|
||||||
@@ -161,6 +204,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
|||||||
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
<KategorieBadge kategorie={s.kategorie} />
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
<StatusBadge status={s.status} />
|
<StatusBadge status={s.status} />
|
||||||
|
<WiderrufBadge s={s} />
|
||||||
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted text-[13px]">
|
<span className="text-muted text-[13px]">
|
||||||
@@ -175,6 +219,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
|||||||
className="hidden md:block"
|
className="hidden md:block"
|
||||||
beschriftung="Schlüssel"
|
beschriftung="Schlüssel"
|
||||||
spalten={[
|
spalten={[
|
||||||
|
...(massen ? [{ titel: "Auswahl", versteckt: true, className: "w-10" }] : []),
|
||||||
{ titel: "Name" },
|
{ titel: "Name" },
|
||||||
{ titel: "Typ" },
|
{ titel: "Typ" },
|
||||||
{ titel: "Kategorie" },
|
{ titel: "Kategorie" },
|
||||||
@@ -185,6 +230,11 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
|||||||
>
|
>
|
||||||
{liste.map((s) => (
|
{liste.map((s) => (
|
||||||
<TabellenZeile key={s.id}>
|
<TabellenZeile key={s.id}>
|
||||||
|
{massen && (
|
||||||
|
<TabellenZelle className="w-10">
|
||||||
|
<Auswahl s={s} />
|
||||||
|
</TabellenZelle>
|
||||||
|
)}
|
||||||
<TabellenZelle>
|
<TabellenZelle>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
@@ -195,6 +245,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
|||||||
</Link>
|
</Link>
|
||||||
<PrivatHinweis s={s} />
|
<PrivatHinweis s={s} />
|
||||||
<StatusBadge status={s.status} />
|
<StatusBadge status={s.status} />
|
||||||
|
<WiderrufBadge s={s} />
|
||||||
</span>
|
</span>
|
||||||
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
|
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
|
||||||
</TabellenZelle>
|
</TabellenZelle>
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Ban } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { StatusBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||||
|
import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular";
|
||||||
|
import { idsAusSuchparameter } from "@/lib/schluessel";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { widerrufUebersicht } from "@/server/queries/widerruf";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Massenwiderruf" };
|
||||||
|
|
||||||
|
type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem
|
||||||
|
* Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische
|
||||||
|
* Bestaetigung (Recht `massenwiderruf`). Protokolliert wird je Schluessel.
|
||||||
|
*/
|
||||||
|
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
|
const parameter = await searchParams;
|
||||||
|
const ids = idsAusSuchparameter(parameter.id);
|
||||||
|
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
|
||||||
|
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
|
||||||
|
|
||||||
|
const kopf = (
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: "Massenwiderruf" }]} />}
|
||||||
|
titel="Massenwiderruf"
|
||||||
|
untertitel="Mehrere Schlüssel gemeinsam von allen Hosts entfernen – ein Dry-Run für alle."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
{kopf}
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Ban className="size-5" />}
|
||||||
|
titel="Keine Schlüssel gewählt"
|
||||||
|
text="Wählen Sie in der Schlüsselliste die Schlüssel aus, die widerrufen werden sollen."
|
||||||
|
aktion={<Button href="/schluessel">Zur Schlüsselliste</Button>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uebersicht = await widerrufUebersicht(ids);
|
||||||
|
const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN";
|
||||||
|
const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet);
|
||||||
|
const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet);
|
||||||
|
const vorauswahl = idsAusSuchparameter(parameter.zielkonten);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
{kopf}
|
||||||
|
|
||||||
|
{nochNichtEingeleitet.length > 0 || uebersicht.schluessel.some((s) => s.gesperrt && !s.eingeleitet) ? (
|
||||||
|
<Karte
|
||||||
|
titel={`${uebersicht.schluessel.length} Schlüssel widerrufen?`}
|
||||||
|
untertitel="Genau das passiert – erst nach Dry-Run und „Anwenden“ auf den Hosts."
|
||||||
|
>
|
||||||
|
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
|
||||||
|
<p className="text-accent mb-4 text-[13.5px]">
|
||||||
|
Der Massenwiderruf verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
|
||||||
|
Vormerken danach.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<WiderrufFormular uebersicht={uebersicht} ruecksprung={selbst} />
|
||||||
|
</Karte>
|
||||||
|
) : laufend.length > 0 ? (
|
||||||
|
<Karte
|
||||||
|
titel="Widerruf läuft"
|
||||||
|
untertitel="Ein gemeinsamer Dry-Run für alle Schlüssel. Widerrufen ist ein Schlüssel erst, wenn er überall entfernt ist."
|
||||||
|
className="border-danger-border"
|
||||||
|
>
|
||||||
|
<WiderrufAblauf
|
||||||
|
schluesselIds={laufend.map((s) => s.id)}
|
||||||
|
offen={uebersicht.offen}
|
||||||
|
zielkonten={uebersicht.zielkonten}
|
||||||
|
vorauswahl={vorauswahl}
|
||||||
|
ruecksprung={selbst}
|
||||||
|
darfPrivat={darf(benutzer.rolle, "privateKeyAusrollen")}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Karte titel="Gewählte Schlüssel">
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{uebersicht.schluessel.map((s) => (
|
||||||
|
<li key={s.id} className="flex flex-wrap items-center gap-2 text-[14px]">
|
||||||
|
<Link href={`/schluessel/${s.id}`} className="text-accent hover:underline">
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
{s.person && <span className="text-muted">· {s.person}</span>}
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
{s.eingeleitet && offeneStati(s) && (
|
||||||
|
<span className="text-danger text-[13px]">Widerruf unvollständig</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Karte>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { VorlageArchivierenDialog } from "@/components/vorlagen/VorlageArchivierenDialog";
|
||||||
|
import { VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { vorlageDetail } from "@/server/queries/vorlagen";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Vorlage bearbeiten" };
|
||||||
|
|
||||||
|
export default async function VorlageBearbeiten({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
await sitzungErforderlich("einstellungenVerwalten", `/vorlagen/${id}`);
|
||||||
|
const v = await vorlageDetail(id);
|
||||||
|
// Archivierte Vorlagen sind fuer die Oberflaeche weg; das Protokoll kennt sie noch (#019).
|
||||||
|
if (!v || v.archiviertAm) notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: v.name }]} />}
|
||||||
|
titel={`${v.name} bearbeiten`}
|
||||||
|
badges={v.eingebaut ? <Badge>eingebaut</Badge> : undefined}
|
||||||
|
untertitel={
|
||||||
|
v.eingebaut
|
||||||
|
? "Eingebaute Vorlagen lassen sich anpassen, aber nicht archivieren. Ein späterer Seed überschreibt Ihre Änderungen nicht."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
aktionen={!v.eingebaut && <VorlageArchivierenDialog vorlageId={v.id} name={v.name} />}
|
||||||
|
/>
|
||||||
|
<VorlagenFormular
|
||||||
|
vorgabe={{
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
beschreibung: v.beschreibung ?? "",
|
||||||
|
// Das Formular kennt nur, was Vorlagen erzeugen; ANDERER/DEPLOYMENT kommen hier nicht vor.
|
||||||
|
schluesselTyp: v.schluesselTyp === "RSA4096" ? "RSA4096" : "ED25519",
|
||||||
|
kategorie: v.kategorie === "PERSON" ? "PERSON" : "DIENST",
|
||||||
|
standardLaufzeitTage: v.standardLaufzeitTage,
|
||||||
|
zielbenutzer: v.zielbenutzer,
|
||||||
|
zielbenutzerAnlegen: v.zielbenutzerAnlegen,
|
||||||
|
sudoModus: v.sudoModus,
|
||||||
|
sudoBefehle: v.sudoBefehle,
|
||||||
|
authorizedKeysOptionen: v.authorizedKeysOptionen ?? "",
|
||||||
|
sshConfigAlias: v.sshConfigAlias ?? "",
|
||||||
|
knownHostsEintragen: v.knownHostsEintragen,
|
||||||
|
privatNachAusrollenLoeschen: v.privatNachAusrollenLoeschen,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { LEERE_VORLAGE, VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Vorlage anlegen" };
|
||||||
|
|
||||||
|
export default async function VorlageNeu() {
|
||||||
|
// Vorlagen pflegen nur Admins (#019) – wie die uebrigen Einstellungen.
|
||||||
|
await sitzungErforderlich("einstellungenVerwalten", "/vorlagen/neu");
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: "Neue Vorlage" }]} />}
|
||||||
|
titel="Vorlage anlegen"
|
||||||
|
untertitel="Eine Vorlage füllt den Assistenten vor; im Einzelfall lässt sich alles noch ändern."
|
||||||
|
/>
|
||||||
|
<VorlagenFormular vorgabe={LEERE_VORLAGE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,137 @@
|
|||||||
|
import { FileStack, Pencil, Plus } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import { Fragment, type ReactNode } from "react";
|
||||||
|
import { KategorieBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { vorlagenListe, type VorlagenListenEintrag } from "@/server/queries/vorlagen";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Vorlagen" };
|
export const metadata: Metadata = { title: "Vorlagen" };
|
||||||
|
|
||||||
export default async function Vorlagen() {
|
const SUDO_TEXT = { KEINER: null, BEFEHLE: "sudo: bestimmte Befehle", VOLL: "sudo: voll" } as const;
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
function Angabe({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
titel="Vorlagen"
|
<dt className="text-muted text-[12.5px] font-medium">{titel}</dt>
|
||||||
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup"
|
<dd className="min-w-0 text-[14px]">{children}</dd>
|
||||||
phase={3}
|
</div>
|
||||||
issue="#019"
|
);
|
||||||
/>
|
}
|
||||||
|
|
||||||
|
function Mono({ wert }: { wert: string | null }) {
|
||||||
|
if (!wert) return <span className="text-muted">–</span>;
|
||||||
|
// Lange Optionslisten sollen auf 390 px nach einem Komma umbrechen, nicht mitten im Wort (#019).
|
||||||
|
const teile = wert.split(",");
|
||||||
|
return (
|
||||||
|
<code className="text-text font-mono text-[13px] [overflow-wrap:anywhere]">
|
||||||
|
{teile.map((teil, i) => (
|
||||||
|
<Fragment key={i}>
|
||||||
|
{teil}
|
||||||
|
{i < teile.length - 1 && (
|
||||||
|
<>
|
||||||
|
,<wbr />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VorlagenKarte({ v, verwalten }: { v: VorlagenListenEintrag; verwalten: boolean }) {
|
||||||
|
const sudo = SUDO_TEXT[v.sudoModus];
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
as="article"
|
||||||
|
titel={v.name}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button
|
||||||
|
href={`/vorlagen/${v.id}`}
|
||||||
|
klein
|
||||||
|
icon={<Pencil className="size-3.5" />}
|
||||||
|
aria-label={`${v.name} bearbeiten`}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="-mt-2 mb-4 flex flex-wrap items-center gap-1.5">
|
||||||
|
<KategorieBadge kategorie={v.kategorie} />
|
||||||
|
<Badge mono>{v.schluesselTyp === "RSA4096" ? "rsa-4096" : "ed25519"}</Badge>
|
||||||
|
{v.eingebaut && <Badge>eingebaut</Badge>}
|
||||||
|
{v.standardLaufzeitTage && (
|
||||||
|
<Badge variante="messing">
|
||||||
|
{v.standardLaufzeitTage === 1 ? "1 Tag" : `${v.standardLaufzeitTage} Tage`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{v.beschreibung && <p className="text-muted mb-4 text-[14px]">{v.beschreibung}</p>}
|
||||||
|
<dl className="border-border mt-auto grid gap-3 border-t pt-4">
|
||||||
|
<Angabe titel="Zielbenutzer">
|
||||||
|
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<Mono wert={v.zielbenutzer} />
|
||||||
|
{v.zielbenutzerAnlegen && <span className="text-muted text-[13px]">wird angelegt</span>}
|
||||||
|
{sudo && (
|
||||||
|
<Badge variante={v.sudoModus === "VOLL" ? "gefahr" : "neutral"}>
|
||||||
|
{sudo}
|
||||||
|
{v.sudoModus === "BEFEHLE" && ` (${v.sudoBefehle.length})`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Angabe>
|
||||||
|
<Angabe titel="authorized_keys-Optionen">
|
||||||
|
<Mono wert={v.authorizedKeysOptionen} />
|
||||||
|
</Angabe>
|
||||||
|
<Angabe titel="Alias-Muster">
|
||||||
|
<Mono wert={v.sshConfigAlias} />
|
||||||
|
</Angabe>
|
||||||
|
</dl>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorlagen (#019): lesen darf jeder, anlegen und bearbeiten nur, wer Einstellungen verwaltet. */
|
||||||
|
export default async function Vorlagen() {
|
||||||
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen");
|
||||||
|
const vorlagen = await vorlagenListe();
|
||||||
|
const verwalten = darf(benutzer.rolle, "einstellungenVerwalten");
|
||||||
|
const anlegen = verwalten && (
|
||||||
|
<Button href="/vorlagen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Vorlage anlegen
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Vorlagen"
|
||||||
|
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup – sie füllen den Assistenten vor."
|
||||||
|
aktionen={anlegen}
|
||||||
|
/>
|
||||||
|
{vorlagen.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<FileStack className="size-5" />}
|
||||||
|
titel="Keine Vorlagen"
|
||||||
|
text="Die eingebauten Vorlagen legt der Seed an (pnpm db:seed)."
|
||||||
|
aktion={anlegen}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
|
||||||
|
{vorlagen.map((v) => (
|
||||||
|
<VorlagenKarte key={v.id} v={v} verwalten={verwalten} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,164 @@
|
|||||||
|
import { Download, Network, Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import { Zuweisungsmatrix } from "@/components/zuweisungen/Zuweisungsmatrix";
|
||||||
|
import { Button, buttonKlassen } from "@/components/ui/Button";
|
||||||
|
import { Ankreuzfeld, Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||||
|
import { Kennzahl } from "@/components/ui/Kennzahl";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { KATEGORIE_TEXT } from "@/lib/schluessel";
|
||||||
|
import { matrixFilterAus, matrixFilterQuery } from "@/lib/zuweisungsmatrix";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { zielAuswahl } from "@/server/queries/ziele";
|
||||||
|
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Zuweisungen" };
|
export const metadata: Metadata = { title: "Zuweisungen" };
|
||||||
|
|
||||||
export default async function Zuweisungen() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zuweisungsmatrix Schluessel × Host (#025). Filter per GET, damit Links und der CSV-Export
|
||||||
|
* denselben Ausschnitt zeigen. Die Matrix plant nur – ausgerollt wird ueber den Dry-Run.
|
||||||
|
*/
|
||||||
|
export default async function Zuweisungen({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
await sitzungErforderlich();
|
const { benutzer } = await sitzungErforderlich("lesen", "/zuweisungen");
|
||||||
|
const filter = matrixFilterAus(await searchParams);
|
||||||
|
const darfZuweisen = darf(benutzer.rolle, "publicKeyAusrollen");
|
||||||
|
const [{ matrix, gruppen, personen }, auswahl] = await Promise.all([
|
||||||
|
zuweisungsMatrix(filter),
|
||||||
|
darfZuweisen ? zielAuswahl() : Promise.resolve(undefined),
|
||||||
|
]);
|
||||||
|
const gefiltert = Boolean(
|
||||||
|
filter.gruppeId || filter.kategorie || filter.personId || filter.q || filter.nurBelegt,
|
||||||
|
);
|
||||||
|
const { zaehler } = matrix;
|
||||||
|
const leer = matrix.zeilen.length === 0 || matrix.spalten.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite
|
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||||
titel="Zuweisungen"
|
<Seitenkopf
|
||||||
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt"
|
titel="Zuweisungen"
|
||||||
phase={2}
|
untertitel="Welcher Schlüssel auf welchem Host verwaltet ist"
|
||||||
issue="#016"
|
aktionen={
|
||||||
/>
|
<a
|
||||||
|
href={`/api/zuweisungen.csv${matrixFilterQuery(filter)}`}
|
||||||
|
download
|
||||||
|
className={buttonKlassen("sekundaer")}
|
||||||
|
>
|
||||||
|
<Download className="size-4" aria-hidden />
|
||||||
|
CSV exportieren
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:gap-5 xl:grid-cols-4">
|
||||||
|
<Kennzahl titel="Schlüssel" wert={zaehler.schluessel} hinweis="aktiv, in der Ansicht" />
|
||||||
|
<Kennzahl titel="Hosts" wert={zaehler.hosts} hinweis="nicht archiviert" />
|
||||||
|
<Kennzahl titel="Zuweisungen" wert={zaehler.zuweisungen} hinweis="Gruppen je Host gezählt" />
|
||||||
|
<Kennzahl
|
||||||
|
titel="Nicht synchron"
|
||||||
|
wert={zaehler.nichtSynchron}
|
||||||
|
variante={zaehler.nichtSynchron > 0 ? "messing" : "neutral"}
|
||||||
|
hinweis="geplant, Drift, entfernen, Fehler"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="get"
|
||||||
|
role="search"
|
||||||
|
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_10rem_11rem]"
|
||||||
|
>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={filter.q}
|
||||||
|
placeholder="Schlüssel- oder Hostname"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="sm:col-span-2 lg:col-span-1"
|
||||||
|
/>
|
||||||
|
<Auswahlliste beschriftung="Hostgruppe" name="gruppe" defaultValue={filter.gruppeId}>
|
||||||
|
<option value="">Alle Hosts</option>
|
||||||
|
{gruppen.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={filter.kategorie}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Person" name="person" defaultValue={filter.personId}>
|
||||||
|
<option value="">Alle Personen</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="flex flex-col gap-3 sm:col-span-2 sm:flex-row sm:items-center sm:justify-between lg:col-span-4">
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="belegt"
|
||||||
|
value="1"
|
||||||
|
defaultChecked={filter.nurBelegt}
|
||||||
|
beschriftung="Nur Zeilen und Spalten mit Zuweisungen"
|
||||||
|
className="-mx-2"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||||
|
Filtern
|
||||||
|
</Button>
|
||||||
|
{gefiltert && (
|
||||||
|
<Button href="/zuweisungen" className="flex-1 sm:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{leer ? (
|
||||||
|
gefiltert ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Search className="size-5" />}
|
||||||
|
titel="Keine Treffer"
|
||||||
|
text="Kein Schlüssel und kein Host passen zu Suche und Filter."
|
||||||
|
aktion={<Button href="/zuweisungen">Filter zurücksetzen</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Network className="size-5" />}
|
||||||
|
titel="Noch nichts zuzuordnen"
|
||||||
|
text="Die Matrix braucht mindestens einen aktiven Schlüssel und einen Host."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<section className="flex min-w-0 flex-col gap-3" aria-label="Matrix">
|
||||||
|
<p className="text-muted text-[13px]">
|
||||||
|
<span className="text-text font-medium">P</span> Public Key direkt ·{" "}
|
||||||
|
<span className="text-text font-medium">P·G</span> über Hostgruppe ·{" "}
|
||||||
|
<span className="text-text font-medium">K</span> Private Key · <span className="text-ok">●</span>{" "}
|
||||||
|
synchron · <span className="text-accent">○</span> geplant oder wird entfernt ·{" "}
|
||||||
|
<span className="text-danger">●</span> Drift oder Fehler
|
||||||
|
</p>
|
||||||
|
<Zuweisungsmatrix
|
||||||
|
zeilen={matrix.zeilen}
|
||||||
|
spalten={matrix.spalten}
|
||||||
|
zellen={Object.fromEntries(matrix.zellen)}
|
||||||
|
darfZuweisen={darfZuweisen}
|
||||||
|
darfPrivatEntfernen={darf(benutzer.rolle, "privateKeyAusrollen")}
|
||||||
|
{...(auswahl ? { auswahl } : {})}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { CSV_BOM, csvZeile } from "@/lib/csv";
|
||||||
|
import { PROTOKOLL_CSV_KOPF, protokollCsvWerte, protokollFilterAus } from "@/lib/protokoll-filter";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { protokollStapelweise } from "@/server/queries/protokoll";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protokoll als CSV (#027) mit denselben Filtern wie die Seite. Jede Rolle darf lesen; der
|
||||||
|
* Handler prueft das selbst.
|
||||||
|
*
|
||||||
|
* Gestreamt: die Datenbank liefert Stapel zu 500 Zeilen, jeder Stapel geht sofort hinaus.
|
||||||
|
* Ein Protokoll mit Hunderttausenden Eintraegen muss so nie ganz in den Speicher.
|
||||||
|
*/
|
||||||
|
export async function GET(anfrage: Request) {
|
||||||
|
let benutzer;
|
||||||
|
try {
|
||||||
|
({ benutzer } = await rechtErforderlich("lesen"));
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const filter = protokollFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
|
||||||
|
|
||||||
|
// Vor dem ersten Byte: wer das Protokoll mitnimmt, steht selbst darin – und scheitert das
|
||||||
|
// Schreiben, gibt es auch keinen Export.
|
||||||
|
await protokollieren({
|
||||||
|
akteur: { id: benutzer.id, name: benutzer.name },
|
||||||
|
aktion: "protokoll.exportiert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { filter: Object.fromEntries(Object.entries(filter).filter(([, wert]) => wert)) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const kodierer = new TextEncoder();
|
||||||
|
const stapel = protokollStapelweise(filter);
|
||||||
|
const strom = new ReadableStream<Uint8Array>({
|
||||||
|
start(steuerung) {
|
||||||
|
steuerung.enqueue(kodierer.encode(CSV_BOM + csvZeile(PROTOKOLL_CSV_KOPF) + "\r\n"));
|
||||||
|
},
|
||||||
|
// pull statt Schleife in start: der naechste Stapel wird erst gelesen, wenn der Client
|
||||||
|
// den vorigen abgenommen hat (Gegendruck).
|
||||||
|
async pull(steuerung) {
|
||||||
|
try {
|
||||||
|
const { value, done } = await stapel.next();
|
||||||
|
if (done) {
|
||||||
|
steuerung.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
steuerung.enqueue(
|
||||||
|
kodierer.encode(value.map((z) => csvZeile(protokollCsvWerte(z)) + "\r\n").join("")),
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
steuerung.error(fehler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
await stapel.return(undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tag = new Date().toISOString().slice(0, 10);
|
||||||
|
return new NextResponse(strom, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="sshkv-protokoll-${tag}.csv"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { matrixCsv, matrixFilterAus } from "@/lib/zuweisungsmatrix";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zuweisungsmatrix als CSV (#025) mit denselben Filtern wie die Seite. Jede Rolle darf lesen;
|
||||||
|
* der Handler prueft das selbst, der Proxy laesst ohne Cookie ohnehin niemanden durch.
|
||||||
|
*/
|
||||||
|
export async function GET(anfrage: Request) {
|
||||||
|
try {
|
||||||
|
await rechtErforderlich("lesen");
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const filter = matrixFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
|
||||||
|
const { matrix } = await zuweisungsMatrix(filter);
|
||||||
|
return new NextResponse(matrixCsv(matrix), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
"Content-Disposition": 'attachment; filename="sshkv-zuweisungen.csv"',
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* BEISPIELDATEN für den klickbaren Prototyp (#002) – keine echten Hosts oder Schlüssel.
|
|
||||||
* Die echten Lesemodelle kommen in #021 (Abgleich) und #026 (Übersicht); dann entfällt
|
|
||||||
* diese Datei. Alle Zeiten beziehen sich auf BEISPIEL_JETZT, damit der statisch gebaute
|
|
||||||
* Prototyp nicht mit jedem Build andere Relativangaben zeigt.
|
|
||||||
*/
|
|
||||||
import type { HostStatus } from "@/components/ui/Statuspunkt";
|
|
||||||
|
|
||||||
export const BEISPIEL_JETZT = "2026-10-12T07:00:00Z"; // 09:00 Berliner Zeit
|
|
||||||
|
|
||||||
export const letzterAbgleich = "2026-10-12T06:42:00Z";
|
|
||||||
|
|
||||||
export const kennzahlen = {
|
|
||||||
hosts: { wert: 42, hinweis: "38 synchron · 1 nicht erreichbar" },
|
|
||||||
schluessel: { wert: 117, hinweis: "davon 9 mit Private Key" },
|
|
||||||
abweichungen: { wert: 3, hinweis: "auf 3 Hosts" },
|
|
||||||
ablaufend: { wert: 5, hinweis: "in den nächsten 30 Tagen" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Abweichung = {
|
|
||||||
host: string;
|
|
||||||
benutzer: string;
|
|
||||||
status: HostStatus;
|
|
||||||
befund: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const abweichungen: Abweichung[] = [
|
|
||||||
{ host: "srv-web03.intern", benutzer: "root", status: "drift", befund: "1 fremder Schlüssel" },
|
|
||||||
{ host: "srv-db01.intern", benutzer: "deploy", status: "drift", befund: "Schlüssel fehlt" },
|
|
||||||
{ host: "fw-edge01.intern", benutzer: "sshkv", status: "drift", befund: "Optionen geändert" },
|
|
||||||
{ host: "srv-legacy02.intern", benutzer: "root", status: "unerreichbar", befund: "nicht erreichbar" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export type AblaufEintrag = { zweck: string; person: string; typ: string; ablauf: string };
|
|
||||||
|
|
||||||
export const baldAblaufend: AblaufEintrag[] = [
|
|
||||||
{ zweck: "Laptop Florian", person: "Florian Wilde", typ: "ed25519", ablauf: "2026-10-16T10:00:00Z" },
|
|
||||||
{ zweck: "CI-Deployment Shop", person: "Dienst", typ: "ed25519", ablauf: "2026-10-21T10:00:00Z" },
|
|
||||||
{
|
|
||||||
zweck: "Backup srv-backup01 → srv-pve01",
|
|
||||||
person: "Dienst",
|
|
||||||
typ: "ed25519",
|
|
||||||
ablauf: "2026-10-30T10:00:00Z",
|
|
||||||
},
|
|
||||||
{ zweck: "Notebook Sandra", person: "Sandra Keller", typ: "rsa-4096", ablauf: "2026-11-04T10:00:00Z" },
|
|
||||||
{ zweck: "Monitoring Zabbix", person: "Dienst", typ: "ed25519", ablauf: "2026-11-09T10:00:00Z" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export type AktionsArt = "ausgerollt" | "widerrufen" | "erstellt" | "abgleich" | "rotiert";
|
|
||||||
|
|
||||||
export type LetzteAktion = {
|
|
||||||
zeit: string;
|
|
||||||
art: AktionsArt;
|
|
||||||
beschreibung: string;
|
|
||||||
benutzer: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const letzteAktionen: LetzteAktion[] = [
|
|
||||||
{
|
|
||||||
zeit: "2026-10-12T06:42:00Z",
|
|
||||||
art: "abgleich",
|
|
||||||
beschreibung: "Abgleich aller Hosts: 3 Abweichungen",
|
|
||||||
benutzer: "System",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
zeit: "2026-10-12T06:15:00Z",
|
|
||||||
art: "ausgerollt",
|
|
||||||
beschreibung: "Public Key „Backup srv-backup01 → srv-pve01“ auf backup@srv-pve01",
|
|
||||||
benutzer: "Florian Wilde",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
zeit: "2026-10-11T15:30:00Z",
|
|
||||||
art: "widerrufen",
|
|
||||||
beschreibung: "Schlüssel „Laptop alt Thomas“ von 6 Hosts entfernt",
|
|
||||||
benutzer: "Florian Wilde",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
zeit: "2026-10-11T12:04:00Z",
|
|
||||||
art: "rotiert",
|
|
||||||
beschreibung: "CI-Deployment Shop durch neuen ed25519-Schlüssel ersetzt",
|
|
||||||
benutzer: "Sandra Keller",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
zeit: "2026-10-11T09:20:00Z",
|
|
||||||
art: "erstellt",
|
|
||||||
beschreibung: "Schlüssel „Monitoring Zabbix“ aus Vorlage Deployment-Zugang",
|
|
||||||
benutzer: "Sandra Keller",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||||
|
import { datumZeit } from "@/lib/format";
|
||||||
|
import { abgleichAktion } from "@/server/actions/abgleich";
|
||||||
|
import type { IstZustand } from "@/server/queries/abgleich";
|
||||||
|
import { UebernehmenDialog } from "./UebernehmenDialog";
|
||||||
|
|
||||||
|
/** Abweichungen und Ist-Zustand eines Hosts (#021, #022). */
|
||||||
|
const ART_TEXT: Record<string, string> = {
|
||||||
|
UNBEKANNTER_SCHLUESSEL: "Unbekannter Schlüssel",
|
||||||
|
FEHLENDER_SCHLUESSEL: "Schlüssel fehlt",
|
||||||
|
ZUSAETZLICH_IM_BLOCK: "Block von Hand geändert",
|
||||||
|
DATEI_GEAENDERT: "Block beschädigt",
|
||||||
|
RECHTE: "Rechte",
|
||||||
|
HOSTKEY_GEAENDERT: "Host-Key geändert",
|
||||||
|
PRIVATE_KEY_FEHLT: "Private Key fehlt/verändert",
|
||||||
|
};
|
||||||
|
|
||||||
|
const HERKUNFT: Record<string, { text: string; variante: "ok" | "neutral" | "gefahr" }> = {
|
||||||
|
verwaltet: { text: "verwaltet", variante: "ok" },
|
||||||
|
bekannt: { text: "bekannt", variante: "neutral" },
|
||||||
|
fremd: { text: "unbekannt", variante: "gefahr" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AbgleichKarten({
|
||||||
|
hostId,
|
||||||
|
zustand,
|
||||||
|
darfAbgleichen,
|
||||||
|
darfUebernehmen,
|
||||||
|
darfVerwalten,
|
||||||
|
}: {
|
||||||
|
hostId: string;
|
||||||
|
zustand: IstZustand;
|
||||||
|
darfAbgleichen: boolean;
|
||||||
|
darfUebernehmen: boolean;
|
||||||
|
darfVerwalten: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Karte
|
||||||
|
titel="Abweichungen"
|
||||||
|
untertitel="Unterschiede zwischen Soll und Host beim letzten Abgleich"
|
||||||
|
aktionen={
|
||||||
|
darfAbgleichen ? (
|
||||||
|
<VorgangsKnopf aktion={abgleichAktion.bind(null, hostId)} beschriftung="Jetzt abgleichen" />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{zustand.abweichungen.length === 0 ? (
|
||||||
|
<p className="text-ok text-[14px]">● Keine offenen Abweichungen.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{zustand.abweichungen.map((a) => (
|
||||||
|
<li
|
||||||
|
key={a.id}
|
||||||
|
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<span className="text-danger text-[14px] font-medium">
|
||||||
|
● {ART_TEXT[a.art] ?? a.art}
|
||||||
|
{a.zielbenutzer && (
|
||||||
|
<span className="text-muted font-mono text-[13px]"> · {a.zielbenutzer}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-text text-[14px]">{a.text}</span>
|
||||||
|
<span className="text-muted font-mono text-[12px] break-all">
|
||||||
|
{a.fingerprint ? `${a.fingerprint} · ` : ""}seit {datumZeit(a.eroeffnetAm)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Ist-Zustand je Benutzer"
|
||||||
|
untertitel="Alle Schlüssel in authorized_keys – auch die, die sshkv nicht verwaltet"
|
||||||
|
>
|
||||||
|
{zustand.beobachtungen.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
titel="Noch nicht abgeglichen"
|
||||||
|
text="Nach dem ersten Abgleich steht hier, was wirklich auf dem Host liegt."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{zustand.beobachtungen.map((b) => (
|
||||||
|
<section key={b.zielbenutzer} className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-text font-mono text-[14px] font-medium">
|
||||||
|
{b.zielbenutzer}{" "}
|
||||||
|
<span className="text-muted text-[12px]">· {datumZeit(b.beobachtetAm)}</span>
|
||||||
|
</h3>
|
||||||
|
{b.eintraege.length === 0 ? (
|
||||||
|
<p className="text-muted text-[13.5px]">Datei leer.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{b.eintraege.map((e) => {
|
||||||
|
const herkunft = HERKUNFT[e.herkunft]!;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${e.fingerprint}-${e.imBlock}`}
|
||||||
|
className="border-border rounded-box flex flex-col gap-2 border p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-mono text-[12.5px] break-all">
|
||||||
|
<span className="text-muted">{e.typ}</span> {e.fingerprint}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted font-mono text-[12px] break-all">
|
||||||
|
{e.kommentar || "(ohne Kommentar)"}
|
||||||
|
{e.optionen ? ` · ${e.optionen}` : ""}
|
||||||
|
{e.imBlock ? " · im verwalteten Block" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Badge variante={herkunft.variante}>{herkunft.text}</Badge>
|
||||||
|
{e.herkunft === "fremd" && darfUebernehmen && (
|
||||||
|
<UebernehmenDialog
|
||||||
|
hostId={hostId}
|
||||||
|
zielbenutzer={b.zielbenutzer}
|
||||||
|
fingerprint={e.fingerprint}
|
||||||
|
kommentar={e.kommentar}
|
||||||
|
personen={zustand.personen}
|
||||||
|
darfVerwalten={darfVerwalten}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { alleAbgleichenAktion } from "@/server/actions/abgleich";
|
||||||
|
|
||||||
|
/** „Jetzt abgleichen“ auf der Uebersicht (#021): reiht fuer jeden Host einen Abgleich ein. */
|
||||||
|
export function AlleAbgleichenKnopf() {
|
||||||
|
const [meldung, setMeldung] = useState<string>();
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-col items-start gap-1">
|
||||||
|
<Button
|
||||||
|
icon={<RefreshCw className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
const antwort = await alleAbgleichenAktion();
|
||||||
|
setMeldung(antwort.fehler ?? `${antwort.anzahl ?? 0} Abgleich(e) eingereiht.`);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Jetzt abgleichen
|
||||||
|
</Button>
|
||||||
|
{meldung && (
|
||||||
|
<span role="status" className="text-muted text-[12.5px]">
|
||||||
|
{meldung}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Ankreuzfeld, Button, Feld, Formularfehler } from "@/components/ui";
|
||||||
|
import { Auswahlliste } from "@/components/ui/Feld";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { uebernehmenAktion } from "@/server/actions/abgleich";
|
||||||
|
|
||||||
|
/** Fremden Key aus dem Ist-Zustand uebernehmen (#022). */
|
||||||
|
export function UebernehmenDialog({
|
||||||
|
hostId,
|
||||||
|
zielbenutzer,
|
||||||
|
fingerprint,
|
||||||
|
kommentar,
|
||||||
|
personen,
|
||||||
|
darfVerwalten,
|
||||||
|
}: {
|
||||||
|
hostId: string;
|
||||||
|
zielbenutzer: string;
|
||||||
|
fingerprint: string;
|
||||||
|
kommentar: string;
|
||||||
|
personen: { id: string; name: string }[];
|
||||||
|
darfVerwalten: boolean;
|
||||||
|
}) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [verwalten, setVerwalten] = useState(darfVerwalten);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||||
|
const ergebnis = await uebernehmenAktion(vorher, formular);
|
||||||
|
if (!ergebnis.fehler && !ergebnis.feldfehler) setOffen(false);
|
||||||
|
return ergebnis;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (offen && !element.open) element.showModal();
|
||||||
|
if (!offen && element.open) element.close();
|
||||||
|
}, [offen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button klein onClick={() => setOffen(true)}>
|
||||||
|
Übernehmen
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-label="Schlüssel übernehmen"
|
||||||
|
onClose={() => setOffen(false)}
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-lg border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<h2 className="font-display mb-1 text-[18px] font-semibold">Schlüssel übernehmen</h2>
|
||||||
|
<p className="text-muted mb-4 font-mono text-[12.5px] break-all">
|
||||||
|
{zielbenutzer} · {fingerprint}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="hostId" value={hostId} />
|
||||||
|
<input type="hidden" name="zielbenutzer" value={zielbenutzer} />
|
||||||
|
<input type="hidden" name="fingerprint" value={fingerprint} />
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<Feld beschriftung="Zweck / Name" name="name" defaultValue={kommentar} required />
|
||||||
|
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue="PERSON">
|
||||||
|
<option value="PERSON">Person</option>
|
||||||
|
<option value="DIENST">Dienst</option>
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Person (optional)" name="personId" defaultValue="">
|
||||||
|
<option value="">– keine –</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
{darfVerwalten && (
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="verwalten"
|
||||||
|
value="ja"
|
||||||
|
checked={verwalten}
|
||||||
|
onChange={(e) => setVerwalten(e.target.checked)}
|
||||||
|
beschriftung="In den verwalteten Block holen"
|
||||||
|
erklaerung="Beim nächsten Ausrollen wandert die Zeile in den Block, die alte Zeile außerhalb wird entfernt – mit Dry-Run."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Übernehme …" : "Übernehmen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, ArrowRight, ExternalLink, Play } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
|
||||||
|
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
|
||||||
|
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||||
|
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||||
|
import { warten } from "@/components/vorgang/warten";
|
||||||
|
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
|
||||||
|
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
|
||||||
|
import {
|
||||||
|
assistentErzeugenAktion,
|
||||||
|
assistentZielkontoAktion,
|
||||||
|
assistentZuweisenAktion,
|
||||||
|
type SchrittAntwort,
|
||||||
|
} from "@/server/actions/assistent";
|
||||||
|
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
|
||||||
|
import type { AssistentDaten } from "@/server/queries/assistent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-zu-Host-Assistent (#020). Vier Schritte Eingabe, dann eine Zusammenfassung, dann die
|
||||||
|
* Ausfuehrung: Schluessel erzeugen → (Zielbenutzer anlegen) → zuweisen → Dry-Run und Ausrollen →
|
||||||
|
* Verbindungstest. Die Ausfuehrung ist wiederaufnehmbar: was schon erledigt ist, wird nicht
|
||||||
|
* wiederholt – wichtig, wenn mittendrin eine erneute Bestaetigung noetig wird.
|
||||||
|
*/
|
||||||
|
type Werte = {
|
||||||
|
vorlageId: string;
|
||||||
|
zweck: string;
|
||||||
|
ablaufAm: string;
|
||||||
|
quelleHostId: string;
|
||||||
|
quelleBenutzer: string;
|
||||||
|
pfad: string;
|
||||||
|
zielHostId: string;
|
||||||
|
zielBenutzer: string;
|
||||||
|
anlegen: boolean;
|
||||||
|
sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||||
|
sudoBefehle: string;
|
||||||
|
optionen: string;
|
||||||
|
quellIp: string;
|
||||||
|
alias: string;
|
||||||
|
hostName: string;
|
||||||
|
port: string;
|
||||||
|
knownHosts: boolean;
|
||||||
|
fingerprintGeprueft: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEER: Werte = {
|
||||||
|
vorlageId: "",
|
||||||
|
zweck: "",
|
||||||
|
ablaufAm: "",
|
||||||
|
quelleHostId: "",
|
||||||
|
quelleBenutzer: "root",
|
||||||
|
pfad: "",
|
||||||
|
zielHostId: "",
|
||||||
|
zielBenutzer: "root",
|
||||||
|
anlegen: false,
|
||||||
|
sudoModus: "KEINER",
|
||||||
|
sudoBefehle: "",
|
||||||
|
optionen: "",
|
||||||
|
quellIp: "",
|
||||||
|
alias: "",
|
||||||
|
hostName: "",
|
||||||
|
port: "22",
|
||||||
|
knownHosts: true,
|
||||||
|
fingerprintGeprueft: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCHRITTE = ["Vorlage & Zweck", "Quelle (Host A)", "Ziel (Host B)", "Prüfen", "Ausrollen"] as const;
|
||||||
|
|
||||||
|
type Ablauf = {
|
||||||
|
schluesselId?: string;
|
||||||
|
zielkontoFertig?: boolean;
|
||||||
|
privatVerwendungId?: string;
|
||||||
|
laeuft?: string;
|
||||||
|
fehler?: string;
|
||||||
|
bestaetigen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function datumIn(tage: number): string {
|
||||||
|
return new Date(Date.now() + tage * 86_400_000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kurz(text: string): string {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 40) || "zugang"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
|
||||||
|
const [schritt, setSchritt] = useState(0);
|
||||||
|
const [w, setW] = useState<Werte>(LEER);
|
||||||
|
const [ablauf, setAblauf] = useState<Ablauf>({});
|
||||||
|
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => setW((alt) => ({ ...alt, [feld]: wert }));
|
||||||
|
|
||||||
|
const vorlage = daten.vorlagen.find((v) => v.id === w.vorlageId);
|
||||||
|
const quelle = daten.hosts.find((h) => h.id === w.quelleHostId);
|
||||||
|
const ziel = daten.hosts.find((h) => h.id === w.zielHostId);
|
||||||
|
|
||||||
|
function vorlageWaehlen(id: string) {
|
||||||
|
const v = daten.vorlagen.find((x) => x.id === id);
|
||||||
|
setW((alt) => ({
|
||||||
|
...alt,
|
||||||
|
vorlageId: id,
|
||||||
|
...(v
|
||||||
|
? {
|
||||||
|
zielBenutzer: v.zielbenutzer,
|
||||||
|
anlegen: v.zielbenutzerAnlegen,
|
||||||
|
sudoModus: v.sudoModus,
|
||||||
|
sudoBefehle: v.sudoBefehle.join("\n"),
|
||||||
|
knownHosts: v.knownHostsEintragen,
|
||||||
|
ablaufAm: v.standardLaufzeitTage ? datumIn(v.standardLaufzeitTage) : "",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beim Wechsel zu „Ziel“: Muster der Vorlage mit A und B aufloesen (nur leere Felder). */
|
||||||
|
function zielVorbelegen(zielId: string) {
|
||||||
|
const b = daten.hosts.find((h) => h.id === zielId);
|
||||||
|
const quellIp = w.quellIp || (quelle && istIpAdresse(quelle.adresse) ? quelle.adresse : "");
|
||||||
|
const werte = {
|
||||||
|
quellHost: quelle?.name,
|
||||||
|
quellHostIp: quellIp,
|
||||||
|
zielHost: b?.name,
|
||||||
|
zielHostIp: b?.adresse,
|
||||||
|
};
|
||||||
|
const aufloesen = (muster: string | null | undefined) => {
|
||||||
|
if (!muster) return "";
|
||||||
|
try {
|
||||||
|
return platzhalterErsetzen(muster, werte);
|
||||||
|
} catch {
|
||||||
|
return muster;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setW((alt) => ({
|
||||||
|
...alt,
|
||||||
|
zielHostId: zielId,
|
||||||
|
quellIp,
|
||||||
|
optionen: alt.optionen || aufloesen(vorlage?.authorizedKeysOptionen),
|
||||||
|
alias: alt.alias || aufloesen(vorlage?.sshConfigAlias) || (b ? b.name : ""),
|
||||||
|
hostName: b ? b.adresse : "",
|
||||||
|
port: b ? String(b.port) : "22",
|
||||||
|
pfad: alt.pfad || `~/.ssh/id_ed25519_${kurz(alt.zweck)}`,
|
||||||
|
fingerprintGeprueft: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const offenePlatzhalter = /\{\{/.test(w.optionen) || /\{\{/.test(w.alias);
|
||||||
|
let sudoVorschau: string | null = null;
|
||||||
|
let sudoFehler: string | undefined;
|
||||||
|
if (w.anlegen) {
|
||||||
|
try {
|
||||||
|
sudoVorschau = sudoersInhalt(
|
||||||
|
w.zielBenutzer,
|
||||||
|
w.sudoModus,
|
||||||
|
w.sudoBefehle.split("\n").filter((z) => z.trim()),
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
sudoFehler = (fehler as Error).message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schrittOk = [
|
||||||
|
w.zweck.trim().length > 0,
|
||||||
|
Boolean(w.quelleHostId && w.quelleBenutzer),
|
||||||
|
Boolean(w.zielHostId && w.zielHostId !== w.quelleHostId && w.zielBenutzer && w.alias && w.hostName) &&
|
||||||
|
!offenePlatzhalter &&
|
||||||
|
!sudoFehler &&
|
||||||
|
(!w.knownHosts || w.fingerprintGeprueft),
|
||||||
|
true,
|
||||||
|
];
|
||||||
|
|
||||||
|
async function ausfuehren() {
|
||||||
|
let stand = { ...ablauf, fehler: undefined, bestaetigen: false } as Ablauf;
|
||||||
|
const melden = (neu: Partial<Ablauf>) => {
|
||||||
|
stand = { ...stand, ...neu };
|
||||||
|
setAblauf(stand);
|
||||||
|
};
|
||||||
|
const pruefen = (antwort: SchrittAntwort) => {
|
||||||
|
if (antwort.fehler || antwort.bestaetigen) {
|
||||||
|
melden({ laeuft: undefined, fehler: antwort.fehler, bestaetigen: Boolean(antwort.bestaetigen) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (!stand.schluesselId) {
|
||||||
|
melden({ laeuft: "Schlüsselpaar wird erzeugt …" });
|
||||||
|
const antwort = await assistentErzeugenAktion({
|
||||||
|
name: w.zweck.trim(),
|
||||||
|
vorlageId: w.vorlageId || null,
|
||||||
|
ablaufAm: w.ablaufAm || null,
|
||||||
|
});
|
||||||
|
if (!pruefen(antwort)) return;
|
||||||
|
const ergebnis = await warten(antwort.vorgangId!);
|
||||||
|
if (ergebnis.status !== "ERLEDIGT")
|
||||||
|
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Erzeugen fehlgeschlagen." });
|
||||||
|
melden({ schluesselId: (ergebnis.ergebnis as { schluesselId: string }).schluesselId });
|
||||||
|
}
|
||||||
|
if (w.anlegen && !stand.zielkontoFertig) {
|
||||||
|
melden({ laeuft: `Benutzer ${w.zielBenutzer} wird auf ${ziel?.name} eingerichtet …` });
|
||||||
|
const antwort = await assistentZielkontoAktion({
|
||||||
|
hostId: w.zielHostId,
|
||||||
|
benutzer: w.zielBenutzer,
|
||||||
|
modus: w.sudoModus,
|
||||||
|
befehle: w.sudoBefehle.split("\n").filter((z) => z.trim()),
|
||||||
|
});
|
||||||
|
if (!pruefen(antwort)) return;
|
||||||
|
const ergebnis = await warten(antwort.vorgangId!);
|
||||||
|
if (ergebnis.status !== "ERLEDIGT")
|
||||||
|
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Zielkonto fehlgeschlagen." });
|
||||||
|
melden({ zielkontoFertig: true });
|
||||||
|
}
|
||||||
|
if (!stand.privatVerwendungId) {
|
||||||
|
melden({ laeuft: "Zuweisungen werden angelegt …" });
|
||||||
|
const antwort = await assistentZuweisenAktion({
|
||||||
|
schluesselId: stand.schluesselId!,
|
||||||
|
quelle: { hostId: w.quelleHostId, benutzer: w.quelleBenutzer, pfad: w.pfad },
|
||||||
|
ziel: { hostId: w.zielHostId, benutzer: w.zielBenutzer, optionen: w.optionen.trim() || null },
|
||||||
|
ssh: { alias: w.alias, hostName: w.hostName, port: Number(w.port), knownHosts: w.knownHosts },
|
||||||
|
});
|
||||||
|
if (!pruefen(antwort)) return;
|
||||||
|
melden({ privatVerwendungId: antwort.privatVerwendungId });
|
||||||
|
}
|
||||||
|
melden({ laeuft: undefined });
|
||||||
|
setSchritt(4);
|
||||||
|
} catch (fehler) {
|
||||||
|
melden({ laeuft: undefined, fehler: (fehler as Error).message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} />
|
||||||
|
|
||||||
|
{schritt === 0 && (
|
||||||
|
<Karte
|
||||||
|
titel="Vorlage & Zweck"
|
||||||
|
untertitel="Eine Vorlage füllt Zielbenutzer, Einschränkungen und Ablauf vor."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Vorlage"
|
||||||
|
name="vorlageId"
|
||||||
|
value={w.vorlageId}
|
||||||
|
onChange={(e) => vorlageWaehlen(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">– ohne Vorlage –</option>
|
||||||
|
{daten.vorlagen.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
{vorlage?.beschreibung && <p className="text-muted text-[14px]">{vorlage.beschreibung}</p>}
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zweck"
|
||||||
|
name="zweck"
|
||||||
|
value={w.zweck}
|
||||||
|
onChange={(e) => setze("zweck", e.target.value)}
|
||||||
|
placeholder="Backup srv-backup01 → srv-pve01"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Ablaufdatum (optional)"
|
||||||
|
name="ablaufAm"
|
||||||
|
type="date"
|
||||||
|
value={w.ablaufAm}
|
||||||
|
onChange={(e) => setze("ablaufAm", e.target.value)}
|
||||||
|
hinweis="Projektbezogene Zugänge sollten ablaufen."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 1 && (
|
||||||
|
<Karte
|
||||||
|
titel="Quelle – Host A"
|
||||||
|
untertitel="Dieser Host bekommt den Private Key und meldet sich später an."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Host A"
|
||||||
|
name="quelleHostId"
|
||||||
|
value={w.quelleHostId}
|
||||||
|
onChange={(e) => setze("quelleHostId", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Host wählen …</option>
|
||||||
|
{daten.hosts.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Benutzer auf A"
|
||||||
|
name="quelleBenutzer"
|
||||||
|
value={w.quelleBenutzer}
|
||||||
|
onChange={(e) => setze("quelleBenutzer", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Pfad des Private Keys"
|
||||||
|
name="pfad"
|
||||||
|
value={w.pfad || `~/.ssh/id_ed25519_${kurz(w.zweck)}`}
|
||||||
|
onChange={(e) => setze("pfad", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 2 && (
|
||||||
|
<Karte titel="Ziel – Host B" untertitel="Dieser Host nimmt den Public Key an.">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Host B"
|
||||||
|
name="zielHostId"
|
||||||
|
value={w.zielHostId}
|
||||||
|
onChange={(e) => zielVorbelegen(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Host wählen …</option>
|
||||||
|
{daten.hosts
|
||||||
|
.filter((h) => h.id !== w.quelleHostId)
|
||||||
|
.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Benutzer auf B"
|
||||||
|
name="zielBenutzer"
|
||||||
|
value={w.zielBenutzer}
|
||||||
|
onChange={(e) => setze("zielBenutzer", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
{w.zielBenutzer === "root" && (
|
||||||
|
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[13.5px]">
|
||||||
|
▲ Zugang als root: Wer den Private Key auf A nutzt – etwa ein KI-Agent wie Claude Code –, hat
|
||||||
|
auf B uneingeschränkten Zugriff. Für Deployments ist ein eigener Benutzer wie „deploy“ besser.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Ankreuzfeld
|
||||||
|
beschriftung={`Benutzer „${w.zielBenutzer}“ auf B anlegen bzw. pflegen`}
|
||||||
|
erklaerung="Mit Home und ~/.ssh; optional mit sudoers-Datei. Verlangt eine frische Bestätigung."
|
||||||
|
checked={w.anlegen}
|
||||||
|
onChange={(e) => setze("anlegen", e.target.checked)}
|
||||||
|
/>
|
||||||
|
{w.anlegen && (
|
||||||
|
<div className="border-border flex flex-col gap-3 border-l-2 pl-4">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="sudo"
|
||||||
|
name="sudoModus"
|
||||||
|
value={w.sudoModus}
|
||||||
|
onChange={(e) => setze("sudoModus", e.target.value as Werte["sudoModus"])}
|
||||||
|
>
|
||||||
|
<option value="KEINER">keine sudo-Rechte</option>
|
||||||
|
<option value="BEFEHLE">bestimmte Befehle</option>
|
||||||
|
<option value="VOLL">voller sudo-Zugriff</option>
|
||||||
|
</Auswahlliste>
|
||||||
|
{w.sudoModus === "BEFEHLE" && (
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Erlaubte Befehle (einer je Zeile, absoluter Pfad)"
|
||||||
|
name="sudoBefehle"
|
||||||
|
value={w.sudoBefehle}
|
||||||
|
onChange={(e) => setze("sudoBefehle", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sudoFehler && <p className="text-danger text-[13px]">{sudoFehler}</p>}
|
||||||
|
{sudoVorschau && (
|
||||||
|
<pre className="border-border bg-bg rounded-box overflow-x-auto border p-3 font-mono text-[12.5px]">
|
||||||
|
{sudoVorschau}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{quelle && !istIpAdresse(quelle.adresse) && (
|
||||||
|
<Feld
|
||||||
|
beschriftung={`IP von ${quelle.name} (für from="…")`}
|
||||||
|
name="quellIp"
|
||||||
|
value={w.quellIp}
|
||||||
|
onChange={(e) => setze("quellIp", e.target.value)}
|
||||||
|
mono
|
||||||
|
hinweis="Die Adresse, unter der B den Host A sieht."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Feld
|
||||||
|
beschriftung="Einschränkungen in authorized_keys"
|
||||||
|
name="optionen"
|
||||||
|
value={w.optionen}
|
||||||
|
onChange={(e) => setze("optionen", e.target.value)}
|
||||||
|
mono
|
||||||
|
placeholder={
|
||||||
|
quelle && istIpAdresse(quelle.adresse) ? `from="${quelle.adresse}"` : 'from="10.0.0.5"'
|
||||||
|
}
|
||||||
|
hinweis={
|
||||||
|
offenePlatzhalter
|
||||||
|
? "Es sind noch Platzhalter offen."
|
||||||
|
: 'Empfohlen: from="<IP von A>", damit der Schlüssel nur von dort funktioniert.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Alias auf A"
|
||||||
|
name="alias"
|
||||||
|
value={w.alias}
|
||||||
|
onChange={(e) => setze("alias", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="HostName (aus Sicht von A)"
|
||||||
|
name="hostName"
|
||||||
|
value={w.hostName}
|
||||||
|
onChange={(e) => setze("hostName", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Port"
|
||||||
|
name="port"
|
||||||
|
type="number"
|
||||||
|
value={w.port}
|
||||||
|
onChange={(e) => setze("port", e.target.value)}
|
||||||
|
mono
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Ankreuzfeld
|
||||||
|
beschriftung="Host-Key von B in known_hosts von A eintragen"
|
||||||
|
checked={w.knownHosts}
|
||||||
|
onChange={(e) => setze("knownHosts", e.target.checked)}
|
||||||
|
/>
|
||||||
|
{w.knownHosts && ziel && (
|
||||||
|
<div className="border-border rounded-box flex flex-col gap-2 border p-3">
|
||||||
|
{ziel.hostKeys.map((k) => (
|
||||||
|
<p key={k.fingerprint} className="font-mono text-[12.5px] break-all">
|
||||||
|
<span className="text-muted">{k.algorithmus}</span> {k.fingerprint}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<Ankreuzfeld
|
||||||
|
beschriftung="Fingerprint von B ist bestätigt"
|
||||||
|
erklaerung="Derselbe Host-Key, dem sshkv beim Bootstrap von B vertraut hat."
|
||||||
|
checked={w.fingerprintGeprueft}
|
||||||
|
onChange={(e) => setze("fingerprintGeprueft", e.target.checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 3 && (
|
||||||
|
<Karte
|
||||||
|
titel="Zusammenfassung"
|
||||||
|
untertitel="Noch ist nichts passiert. Ausgerollt wird erst nach dem Dry-Run."
|
||||||
|
>
|
||||||
|
<dl className="grid gap-x-6 gap-y-3 text-[14px] sm:grid-cols-[12rem_minmax(0,1fr)]">
|
||||||
|
<dt className="text-muted">Schlüssel</dt>
|
||||||
|
<dd>
|
||||||
|
ed25519 · {w.zweck}
|
||||||
|
{w.ablaufAm && (
|
||||||
|
<span className="text-accent">
|
||||||
|
{" "}
|
||||||
|
· läuft ab am {w.ablaufAm.split("-").reverse().join(".")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted">Private Key</dt>
|
||||||
|
<dd className="font-mono text-[13px] break-all">
|
||||||
|
{w.quelleBenutzer}@{quelle?.name}:{w.pfad}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted">~/.ssh/config auf A</dt>
|
||||||
|
<dd className="font-mono text-[13px] break-all">
|
||||||
|
Host {w.alias} → {w.zielBenutzer}@{w.hostName}:{w.port}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted">Public Key</dt>
|
||||||
|
<dd className="font-mono text-[13px] break-all">
|
||||||
|
{w.optionen && `${w.optionen} · `}
|
||||||
|
{w.zielBenutzer}@{ziel?.name}
|
||||||
|
</dd>
|
||||||
|
{w.anlegen && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted">Zielbenutzer</dt>
|
||||||
|
<dd>
|
||||||
|
„{w.zielBenutzer}“ auf {ziel?.name} anlegen,{" "}
|
||||||
|
{w.sudoModus === "KEINER" ? (
|
||||||
|
"ohne sudo"
|
||||||
|
) : w.sudoModus === "VOLL" ? (
|
||||||
|
<span className="text-danger">voller sudo-Zugriff</span>
|
||||||
|
) : (
|
||||||
|
"sudo für bestimmte Befehle"
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
{w.anlegen && !bestaetigt && !ablauf.bestaetigen && (
|
||||||
|
<p className="text-accent mt-4 text-[13.5px]">
|
||||||
|
Das Anlegen des Benutzers verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
|
||||||
|
Ausführen danach.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="border-border mt-5 flex flex-col gap-3 border-t pt-4">
|
||||||
|
<Formularfehler text={ablauf.fehler} />
|
||||||
|
{ablauf.bestaetigen && (
|
||||||
|
<a
|
||||||
|
href="/bestaetigen?ziel=%2Fassistent%2Fbestaetigt"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" aria-hidden /> Anmeldung in neuem Tab bestätigen, dann hier
|
||||||
|
erneut ausführen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{ablauf.laeuft && (
|
||||||
|
<p role="status" className="text-muted text-[14px]">
|
||||||
|
{ablauf.laeuft}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<Play className="size-4" />}
|
||||||
|
disabled={Boolean(ablauf.laeuft)}
|
||||||
|
onClick={() => void ausfuehren()}
|
||||||
|
>
|
||||||
|
{ablauf.schluesselId ? "Fortsetzen" : "Vorbereiten"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 4 && ablauf.schluesselId && (
|
||||||
|
<>
|
||||||
|
<Karte
|
||||||
|
titel="Dry-Run und Ausrollen"
|
||||||
|
untertitel="Prüfen Sie die Änderungen auf A und B, dann anwenden."
|
||||||
|
>
|
||||||
|
<AusrollenPanel auswahl={{ schluesselId: ablauf.schluesselId }} offen={2} />
|
||||||
|
</Karte>
|
||||||
|
<Karte
|
||||||
|
titel="Verbindung testen"
|
||||||
|
untertitel={`Auf ${quelle?.name} als ${w.quelleBenutzer}: ssh ${w.alias} true`}
|
||||||
|
>
|
||||||
|
{ablauf.privatVerwendungId && (
|
||||||
|
<VorgangsKnopf
|
||||||
|
aktion={verbindungTestenAktion.bind(null, ablauf.privatVerwendungId)}
|
||||||
|
beschriftung="Verbindung von A nach B testen"
|
||||||
|
variante="primaer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-muted mt-3 text-[13.5px]">
|
||||||
|
Zum Schlüssel:{" "}
|
||||||
|
<a className="text-accent" href={`/schluessel/${ablauf.schluesselId}`}>
|
||||||
|
{w.zweck}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt < 3 && (
|
||||||
|
<div className="flex flex-wrap justify-between gap-3">
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeft className="size-4" />}
|
||||||
|
disabled={schritt === 0}
|
||||||
|
onClick={() => setSchritt(schritt - 1)}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<ArrowRight className="size-4" />}
|
||||||
|
disabled={!schrittOk[schritt]}
|
||||||
|
onClick={() => {
|
||||||
|
if (schritt === 1 && !w.pfad) setze("pfad", `~/.ssh/id_ed25519_${kurz(w.zweck)}`);
|
||||||
|
setSchritt(schritt + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{schritt === 3 && !ablauf.schluesselId && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
icon={<ArrowLeft className="size-4" />}
|
||||||
|
disabled={Boolean(ablauf.laeuft)}
|
||||||
|
onClick={() => setSchritt(2)}
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { Play, ScanSearch } from "lucide-react";
|
import { Play, ScanSearch } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { DiffBox } from "@/components/ui/DiffBox";
|
import { DiffBox } from "@/components/ui/DiffBox";
|
||||||
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
|
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
|
||||||
@@ -34,7 +34,16 @@ type PlanZiel = {
|
|||||||
fehler?: string;
|
fehler?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; offen: number }) {
|
export function AusrollenPanel({
|
||||||
|
auswahl,
|
||||||
|
offen,
|
||||||
|
nachAusrollen,
|
||||||
|
}: {
|
||||||
|
auswahl: AusrollAuswahl;
|
||||||
|
offen: number;
|
||||||
|
/** Nach erfolgreichem Ausrollen – etwa um einen Widerruf abzuschliessen (#024). */
|
||||||
|
nachAusrollen?: () => void;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [trockenlaufId, setTrockenlaufId] = useState<string>();
|
const [trockenlaufId, setTrockenlaufId] = useState<string>();
|
||||||
const [ausrollenId, setAusrollenId] = useState<string>();
|
const [ausrollenId, setAusrollenId] = useState<string>();
|
||||||
@@ -43,12 +52,22 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
|||||||
const trockenlauf = useVorgang(trockenlaufId);
|
const trockenlauf = useVorgang(trockenlaufId);
|
||||||
const ausrollen = useVorgang(ausrollenId);
|
const ausrollen = useVorgang(ausrollenId);
|
||||||
|
|
||||||
|
// Ueber eine Ref, damit der Rueckruf genau einmal je Lauf feuert und nicht bei jedem Render.
|
||||||
|
const rueckruf = useRef(nachAusrollen);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ausrollen.stand && !laeuftNoch(ausrollen.stand)) router.refresh();
|
rueckruf.current = nachAusrollen;
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ausrollen.stand || laeuftNoch(ausrollen.stand)) return;
|
||||||
|
router.refresh();
|
||||||
|
if (ausrollen.stand.status === "ERLEDIGT") rueckruf.current?.();
|
||||||
}, [ausrollen.stand, router]);
|
}, [ausrollen.stand, router]);
|
||||||
|
|
||||||
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
|
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
|
||||||
const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0;
|
const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0;
|
||||||
|
// Steht schon alles so auf dem Host (etwa ein Key, den jemand von Hand entfernt hat), gibt es
|
||||||
|
// nichts zu schreiben – der Zustand der Zuweisungen muss trotzdem nachgezogen werden (#024).
|
||||||
|
const nurZustand = aenderungen === 0 && offen > 0 && (plan?.some((z) => !z.fehler) ?? false);
|
||||||
const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand);
|
const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand);
|
||||||
const fertig = ausrollen.stand?.status === "ERLEDIGT";
|
const fertig = ausrollen.stand?.status === "ERLEDIGT";
|
||||||
|
|
||||||
@@ -103,7 +122,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{plan && aenderungen > 0 && !fertig && (
|
{plan && (aenderungen > 0 || nurZustand) && !fertig && (
|
||||||
<div className="border-border flex flex-col gap-3 border-t pt-4">
|
<div className="border-border flex flex-col gap-3 border-t pt-4">
|
||||||
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
|
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
|
||||||
<input type="checkbox" checked disabled className="accent-accent size-5" />
|
<input type="checkbox" checked disabled className="accent-accent size-5" />
|
||||||
@@ -124,7 +143,11 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{laeuftNoch(ausrollen.stand) ? "Wird ausgerollt …" : `Änderung anwenden (${aenderungen})`}
|
{laeuftNoch(ausrollen.stand)
|
||||||
|
? "Wird ausgerollt …"
|
||||||
|
: nurZustand
|
||||||
|
? "Zustand übernehmen"
|
||||||
|
: `Änderung anwenden (${aenderungen})`}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
|
<Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
|
||||||
Verwerfen
|
Verwerfen
|
||||||
@@ -132,7 +155,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{plan && aenderungen === 0 && (
|
{plan && aenderungen === 0 && !fertig && (
|
||||||
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
|
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
|
||||||
)}
|
)}
|
||||||
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && (
|
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && (
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ export function PublicZuweisenFormular({
|
|||||||
schluesselId,
|
schluesselId,
|
||||||
auswahl,
|
auswahl,
|
||||||
beiErfolg,
|
beiErfolg,
|
||||||
|
vorauswahl,
|
||||||
}: {
|
}: {
|
||||||
schluesselId: string;
|
schluesselId: string;
|
||||||
auswahl: ZielAuswahl;
|
auswahl: ZielAuswahl;
|
||||||
beiErfolg?: () => void;
|
beiErfolg?: () => void;
|
||||||
|
/** Vorausgewaehltes Ziel als „host:<id>“ oder „gruppe:<id>“, z. B. aus der Matrix (#025). */
|
||||||
|
vorauswahl?: string;
|
||||||
}) {
|
}) {
|
||||||
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||||
const ergebnis = await publicZuweisenAktion(vorher, formular);
|
const ergebnis = await publicZuweisenAktion(vorher, formular);
|
||||||
@@ -28,7 +31,7 @@ export function PublicZuweisenFormular({
|
|||||||
<form onSubmit={absenden} className="flex flex-col gap-4">
|
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||||
<input type="hidden" name="schluesselId" value={schluesselId} />
|
<input type="hidden" name="schluesselId" value={schluesselId} />
|
||||||
<Formularfehler text={zustand.fehler} />
|
<Formularfehler text={zustand.fehler} />
|
||||||
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue="" required>
|
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue={vorauswahl ?? ""} required>
|
||||||
<option value="" disabled>
|
<option value="" disabled>
|
||||||
Host oder Hostgruppe wählen …
|
Host oder Hostgruppe wählen …
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums";
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
|
||||||
/** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */
|
/** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */
|
||||||
const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
|
export const ZUSTAND_ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
|
||||||
GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" },
|
GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" },
|
||||||
AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" },
|
AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" },
|
||||||
ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" },
|
ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" },
|
||||||
@@ -12,7 +12,7 @@ const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) {
|
export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) {
|
||||||
const anzeige = ANZEIGE[zustand];
|
const anzeige = ZUSTAND_ANZEIGE[zustand];
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={clsx(
|
className={clsx(
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Repeat } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { umstellenAktion } from "@/server/actions/deploymentkey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schritt 4 der Rotation (#031). Hosts, auf denen die Pruefung scheiterte, lassen sich bewusst
|
||||||
|
* uebergehen – sshkv kommt danach nicht mehr auf sie und sie brauchen einen neuen Bootstrap.
|
||||||
|
*/
|
||||||
|
export function Umstellen({
|
||||||
|
fehlgeschlagen,
|
||||||
|
}: {
|
||||||
|
fehlgeschlagen: readonly { hostId: string; hostName: string }[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [uebergehen, setUebergehen] = useState<string[]>([]);
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
const bereit = fehlgeschlagen.every((h) => uebergehen.includes(h.hostId));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{fehlgeschlagen.length > 0 && (
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<legend className="text-danger mb-2 text-[13.5px]">
|
||||||
|
Auf diesen Hosts hat der neue Key nicht funktioniert. Übergangene Hosts erreicht sshkv nach dem
|
||||||
|
Umstellen nicht mehr – sie brauchen einen neuen Bootstrap.
|
||||||
|
</legend>
|
||||||
|
{fehlgeschlagen.map((h) => (
|
||||||
|
<label key={h.hostId} className="flex min-h-11 items-center gap-3 text-[14px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-accent size-4"
|
||||||
|
checked={uebergehen.includes(h.hostId)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setUebergehen((alt) =>
|
||||||
|
e.target.checked ? [...alt, h.hostId] : alt.filter((id) => id !== h.hostId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-mono">{h.hostName}</span> übergehen
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<Repeat className="size-4" />}
|
||||||
|
disabled={!bereit || laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
const antwort = await umstellenAktion(uebergehen);
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Auf neuen Key umstellen
|
||||||
|
</Button>
|
||||||
|
{fehler && (
|
||||||
|
<p role="alert" className="text-danger text-[13.5px]">
|
||||||
|
{fehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Mail, Send, Webhook } from "lucide-react";
|
||||||
|
import { startTransition, useActionState, useState, type FormEvent, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Ankreuzfeld,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Feld,
|
||||||
|
Feldgruppe,
|
||||||
|
Formularfehler,
|
||||||
|
Karte,
|
||||||
|
Textbereich,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||||
|
import type { BenachrichtigungAnsicht } from "@/lib/benachrichtigung/einstellungen";
|
||||||
|
import { ANLAESSE, ANLASS_TEXT } from "@/lib/benachrichtigung/regeln";
|
||||||
|
import { benachrichtigungSpeichernAktion, testSendenAktion } from "@/server/actions/einstellungen";
|
||||||
|
import type { FormularZustand } from "@/server/actions/formular";
|
||||||
|
|
||||||
|
type Zustand = FormularZustand & { gespeichert?: boolean };
|
||||||
|
|
||||||
|
/** Fester Anfangszustand: ein neues `{}` je Render liesse den Vergleich unten nie zur Ruhe kommen. */
|
||||||
|
const ANFANG: Zustand = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benachrichtigungskanaele und Anlaesse (#028). Geheimnisse kommen nie zurueck in den Browser:
|
||||||
|
* die Felder starten leer, „gesetzt“ zeigt, dass ein Wert hinterlegt ist, und leer speichern
|
||||||
|
* laesst ihn unveraendert.
|
||||||
|
*/
|
||||||
|
export function BenachrichtigungFormular({
|
||||||
|
ansicht,
|
||||||
|
darfAendern,
|
||||||
|
}: {
|
||||||
|
ansicht: BenachrichtigungAnsicht;
|
||||||
|
darfAendern: boolean;
|
||||||
|
}) {
|
||||||
|
const [zustand, ausfuehren, laeuft] = useActionState<Zustand, FormData>(
|
||||||
|
benachrichtigungSpeichernAktion,
|
||||||
|
ANFANG,
|
||||||
|
);
|
||||||
|
// Nach dem Speichern neu aufbauen: Passwortfelder leeren sich, die Vorgaben kommen frisch
|
||||||
|
// vom Server (revalidatePath). Ueber `key` statt Formular-Reset, damit Fehlerfaelle die
|
||||||
|
// Eingaben behalten (Muster aus useFormularAktion, #008).
|
||||||
|
const [fassung, setFassung] = useState(0);
|
||||||
|
const [gemerkt, setGemerkt] = useState<Zustand>(zustand);
|
||||||
|
if (zustand !== gemerkt) {
|
||||||
|
setGemerkt(zustand);
|
||||||
|
if (zustand.gespeichert) setFassung((f) => f + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function absenden(ereignis: FormEvent<HTMLFormElement>) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
const daten = new FormData(ereignis.currentTarget);
|
||||||
|
startTransition(() => ausfuehren(daten));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
key={fassung}
|
||||||
|
onSubmit={absenden}
|
||||||
|
noValidate
|
||||||
|
className="flex flex-col gap-6"
|
||||||
|
aria-label="Benachrichtigungen"
|
||||||
|
>
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
{zustand.gespeichert && !laeuft && (
|
||||||
|
<p role="status" className="text-ok text-[14px]">
|
||||||
|
Einstellungen gespeichert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Inhalt ansicht={ansicht} darfAendern={darfAendern} f={zustand.feldfehler ?? {}} />
|
||||||
|
{darfAendern && (
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : "Benachrichtigungen speichern"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inhalt({
|
||||||
|
ansicht,
|
||||||
|
darfAendern,
|
||||||
|
f,
|
||||||
|
}: {
|
||||||
|
ansicht: BenachrichtigungAnsicht;
|
||||||
|
darfAendern: boolean;
|
||||||
|
f: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
const [sicherheit, setSicherheit] = useState(ansicht.email.sicherheit);
|
||||||
|
const [httpErlaubt, setHttpErlaubt] = useState(ansicht.webhook.httpErlaubt);
|
||||||
|
const { email, webhook } = ansicht;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Leser sehen dieselben Felder, aber gesperrt und ohne Knoepfe.
|
||||||
|
<fieldset disabled={!darfAendern} className="flex min-w-0 flex-col gap-6">
|
||||||
|
<Karte
|
||||||
|
titel={
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Mail className="text-muted size-4" aria-hidden /> E-Mail (SMTP)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
untertitel="Vorbelegt für Microsoft 365: smtp.office365.com, Port 587, STARTTLS."
|
||||||
|
aktionen={<KanalStatus aktiv={email.aktiv} />}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="emailAktiv"
|
||||||
|
value="ja"
|
||||||
|
defaultChecked={email.aktiv}
|
||||||
|
beschriftung="Per E-Mail benachrichtigen"
|
||||||
|
/>
|
||||||
|
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_6.5rem] gap-3">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Server"
|
||||||
|
name="emailServer"
|
||||||
|
defaultValue={email.server}
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
fehler={f["emailServer"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Port"
|
||||||
|
name="emailPort"
|
||||||
|
defaultValue={email.port}
|
||||||
|
mono
|
||||||
|
inputMode="numeric"
|
||||||
|
fehler={f["emailPort"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Feldgruppe legende="Verschlüsselung" fehler={f["emailSicherheit"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["STARTTLS", "STARTTLS", "Port 587 – Microsoft 365"],
|
||||||
|
["TLS", "TLS (SMTPS)", "Port 465"],
|
||||||
|
["KEINE", "Unverschlüsselt", "nur internes Relay"],
|
||||||
|
] as const
|
||||||
|
).map(([wert, titel, erklaerung]) => (
|
||||||
|
<Ankreuzfeld
|
||||||
|
key={wert}
|
||||||
|
type="radio"
|
||||||
|
name="emailSicherheit"
|
||||||
|
value={wert}
|
||||||
|
defaultChecked={email.sicherheit === wert}
|
||||||
|
onChange={() => setSicherheit(wert)}
|
||||||
|
beschriftung={titel}
|
||||||
|
erklaerung={erklaerung}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{sicherheit === "KEINE" && (
|
||||||
|
<Warnung>
|
||||||
|
Ohne Verschlüsselung gehen Zugangsdaten und Inhalt im Klartext über das Netz. Nur für ein
|
||||||
|
Relay im selben, vertrauenswürdigen Netz verwenden.
|
||||||
|
</Warnung>
|
||||||
|
)}
|
||||||
|
</Feldgruppe>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Absender"
|
||||||
|
name="emailAbsender"
|
||||||
|
type="email"
|
||||||
|
defaultValue={email.absender}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="sshkv@example.de"
|
||||||
|
fehler={f["emailAbsender"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Benutzer (optional)"
|
||||||
|
name="emailBenutzer"
|
||||||
|
defaultValue={email.benutzer}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
hinweis="Bei Microsoft 365 meist die Absenderadresse."
|
||||||
|
fehler={f["emailBenutzer"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<GeheimFeld
|
||||||
|
beschriftung="Passwort"
|
||||||
|
name="emailPasswort"
|
||||||
|
gesetzt={email.passwortGesetzt}
|
||||||
|
darfAendern={darfAendern}
|
||||||
|
fehler={f["emailPasswort"]}
|
||||||
|
/>
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Empfänger"
|
||||||
|
name="emailEmpfaenger"
|
||||||
|
defaultValue={email.empfaenger.join("\n")}
|
||||||
|
rows={3}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="admin@example.de"
|
||||||
|
hinweis="Eine Adresse je Zeile oder durch Komma getrennt."
|
||||||
|
fehler={f["emailEmpfaenger"]}
|
||||||
|
/>
|
||||||
|
{darfAendern && <TestKnopf kanal="EMAIL" />}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel={
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Webhook className="text-muted size-4" aria-hidden /> Webhook
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
untertitel="JSON per POST, optional signiert mit X-Sshkv-Signatur: sha256=<HMAC des Bodys>."
|
||||||
|
aktionen={<KanalStatus aktiv={webhook.aktiv} />}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="webhookAktiv"
|
||||||
|
value="ja"
|
||||||
|
defaultChecked={webhook.aktiv}
|
||||||
|
beschriftung="Per Webhook benachrichtigen"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Adresse"
|
||||||
|
name="webhookUrl"
|
||||||
|
type="url"
|
||||||
|
defaultValue={webhook.url}
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="https://alarm.example.de/hooks/sshkv"
|
||||||
|
fehler={f["webhookUrl"]}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="webhookHttpErlaubt"
|
||||||
|
value="ja"
|
||||||
|
defaultChecked={webhook.httpErlaubt}
|
||||||
|
onChange={(e) => setHttpErlaubt(e.currentTarget.checked)}
|
||||||
|
beschriftung="http:// für ein internes Ziel erlauben"
|
||||||
|
erklaerung="Sonst nimmt sshkv nur https://-Adressen an."
|
||||||
|
/>
|
||||||
|
{httpErlaubt && (
|
||||||
|
<Warnung>
|
||||||
|
Über http:// sind Nutzlast und Signatur unterwegs lesbar und veränderbar. Nur für Ziele im
|
||||||
|
selben, vertrauenswürdigen Netz.
|
||||||
|
</Warnung>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<GeheimFeld
|
||||||
|
beschriftung="Geheimnis für die Signatur (optional)"
|
||||||
|
name="webhookGeheimnis"
|
||||||
|
gesetzt={webhook.geheimnisGesetzt}
|
||||||
|
darfAendern={darfAendern}
|
||||||
|
fehler={f["webhookGeheimnis"]}
|
||||||
|
/>
|
||||||
|
{darfAendern && <TestKnopf kanal="WEBHOOK" />}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Anlässe"
|
||||||
|
untertitel="Drift, Ablauf und Erreichbarkeit kommen gesammelt in der täglichen Zusammenfassung; ein geänderter Host-Key sofort."
|
||||||
|
>
|
||||||
|
<Feldgruppe legende="Benachrichtigen bei" fehler={f["anlaesse"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
{ANLAESSE.map((anlass) => (
|
||||||
|
<Ankreuzfeld
|
||||||
|
key={anlass}
|
||||||
|
name="anlaesse"
|
||||||
|
value={anlass}
|
||||||
|
defaultChecked={ansicht.anlaesse[anlass]}
|
||||||
|
beschriftung={ANLASS_TEXT[anlass].titel}
|
||||||
|
erklaerung={ANLASS_TEXT[anlass].erklaerung}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
</Karte>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KanalStatus({ aktiv }: { aktiv: boolean }) {
|
||||||
|
return aktiv ? <Badge variante="ok">aktiv</Badge> : <Badge>aus</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Warnung({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
role="note"
|
||||||
|
className="border-accent/40 bg-accent-soft text-accent rounded-box mt-2 border px-3 py-2 text-[13.5px]"
|
||||||
|
>
|
||||||
|
▲ {children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passwort bzw. Geheimnis: nie vorbelegt. Ist ein Wert gesetzt, zeigt das Feld nur „gesetzt“;
|
||||||
|
* leer lassen behaelt ihn, das Ankreuzfeld entfernt ihn.
|
||||||
|
*/
|
||||||
|
function GeheimFeld({
|
||||||
|
beschriftung,
|
||||||
|
name,
|
||||||
|
gesetzt,
|
||||||
|
darfAendern,
|
||||||
|
fehler,
|
||||||
|
}: {
|
||||||
|
beschriftung: string;
|
||||||
|
name: string;
|
||||||
|
gesetzt: boolean;
|
||||||
|
darfAendern: boolean;
|
||||||
|
fehler?: string | undefined;
|
||||||
|
}) {
|
||||||
|
if (!darfAendern) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<span className="text-muted text-[13px] font-medium">{beschriftung}</span>
|
||||||
|
<span>{gesetzt ? <Badge variante="ok">gesetzt</Badge> : <Badge>nicht gesetzt</Badge>}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<Feld
|
||||||
|
beschriftung={beschriftung}
|
||||||
|
name={name}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
defaultValue=""
|
||||||
|
placeholder={gesetzt ? "gesetzt – leer lassen, um es zu behalten" : "nicht gesetzt"}
|
||||||
|
hinweis={
|
||||||
|
gesetzt ? (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<Badge variante="ok">gesetzt</Badge> Wird verschlüsselt gespeichert und nie wieder angezeigt.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Wird verschlüsselt gespeichert und nie wieder angezeigt."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fehler={fehler}
|
||||||
|
/>
|
||||||
|
{gesetzt && (
|
||||||
|
<Ankreuzfeld
|
||||||
|
name={`${name}Entfernen`}
|
||||||
|
value="ja"
|
||||||
|
beschriftung={`${beschriftung.replace(/ \(optional\)$/, "")} entfernen`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestKnopf({ kanal }: { kanal: "EMAIL" | "WEBHOOK" }) {
|
||||||
|
return (
|
||||||
|
<div className="border-border flex flex-col gap-2 border-t pt-4">
|
||||||
|
<p className="text-muted text-[13px]">
|
||||||
|
Die Testnachricht nutzt den <strong className="text-text font-medium">gespeicherten</strong> Stand –
|
||||||
|
erst speichern, dann testen. Der Versand läuft im Worker.
|
||||||
|
</p>
|
||||||
|
<VorgangsKnopf
|
||||||
|
aktion={() => testSendenAktion(kanal)}
|
||||||
|
beschriftung="Testnachricht senden"
|
||||||
|
icon={<Send className="size-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ShieldAlert, ShieldCheck, ShieldQuestion } from "lucide-react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { uhrzeit } from "@/lib/format";
|
||||||
|
import { kettePruefenAktion, type KettenAntwort } from "@/server/actions/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integritaet der Hash-Kette (#027). Geprueft wird nur auf Knopfdruck – die Pruefung liest
|
||||||
|
* jede Zeile; ein Ergebnis der letzten 5 Minuten bringt die Seite schon mit.
|
||||||
|
*/
|
||||||
|
export function KettenPruefung({ anfang }: { anfang: KettenAntwort | null }) {
|
||||||
|
const [ergebnis, setErgebnis] = useState<KettenAntwort | null>(anfang);
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
|
||||||
|
const pruefen = () =>
|
||||||
|
starten(async () => {
|
||||||
|
setErgebnis(await kettePruefenAktion());
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<Status ergebnis={ergebnis} />
|
||||||
|
<Button onClick={pruefen} disabled={laeuft} klein className="self-start sm:self-auto">
|
||||||
|
{laeuft ? "Prüft …" : "Kette prüfen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Status({ ergebnis }: { ergebnis: KettenAntwort | null }) {
|
||||||
|
if (!ergebnis) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted flex items-center gap-2 text-[14px]">
|
||||||
|
<ShieldQuestion aria-hidden className="size-4 shrink-0" />
|
||||||
|
Hash-Kette noch nicht geprüft
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ergebnis.fehler) {
|
||||||
|
return <p className="text-danger text-[14px]">{ergebnis.fehler}</p>;
|
||||||
|
}
|
||||||
|
const wann = ergebnis.geprueftAm ? ` um ${uhrzeit(ergebnis.geprueftAm)} Uhr` : "";
|
||||||
|
if (!ergebnis.fehlerhafteId) {
|
||||||
|
return (
|
||||||
|
<p className="text-ok flex items-center gap-2 text-[14px] font-medium">
|
||||||
|
<ShieldCheck aria-hidden className="size-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Hash-Kette unversehrt
|
||||||
|
<span className="text-muted font-normal">
|
||||||
|
{" "}
|
||||||
|
· {ergebnis.eintraege?.toLocaleString("de-DE")} Einträge geprüft{wann}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const id = ergebnis.fehlerhafteId;
|
||||||
|
// Die Seite beginnt beim fehlerhaften Eintrag, der Anker hebt ihn hervor.
|
||||||
|
const ziel = `/protokoll?vor=${(BigInt(id) + 1n).toString()}#eintrag-${id}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="border-danger-border bg-danger-soft text-danger rounded-box flex min-w-0 items-start gap-2.5 border px-3 py-2.5 text-[14px]"
|
||||||
|
>
|
||||||
|
<ShieldAlert aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<p className="min-w-0">
|
||||||
|
<strong className="font-semibold">Hash-Kette beschädigt.</strong> Der Eintrag{" "}
|
||||||
|
<a href={ziel} className="font-mono underline underline-offset-2">
|
||||||
|
#{id}
|
||||||
|
</a>{" "}
|
||||||
|
stimmt nicht mit seinem Hash oder Vorgänger überein – ab hier ist das Protokoll nicht mehr belegbar.
|
||||||
|
Vergleichen Sie mit der Syslog-Kopie und sichern Sie die Datenbank.{wann && ` Geprüft${wann}.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { ChevronDown, History } from "lucide-react";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Badge, type BadgeVariante } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { DiffBox } from "@/components/ui/DiffBox";
|
||||||
|
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||||
|
import { aktionText, aktionTon, type AktionsTon } from "@/lib/protokoll-aktionen";
|
||||||
|
import { diffZeilen, ERGEBNIS_TEXT, protokollZeit } from "@/lib/protokoll-filter";
|
||||||
|
import { fingerprintKurz } from "@/lib/schluessel";
|
||||||
|
import type { ProtokollSeite, ProtokollZeile } from "@/server/queries/protokoll";
|
||||||
|
import { aeltereLadenAktion } from "@/server/actions/protokoll";
|
||||||
|
|
||||||
|
const TON_FARBE: Record<AktionsTon, string> = { ok: "text-ok", gefahr: "text-danger", neutral: "text-text" };
|
||||||
|
const ERGEBNIS_BADGE: Record<ProtokollZeile["ergebnis"], BadgeVariante> = {
|
||||||
|
ERFOLG: "neutral",
|
||||||
|
FEHLER: "gefahr",
|
||||||
|
VERWEIGERT: "gefahr",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protokolleintraege als Tabelle (ab md) bzw. Karten (darunter), jede Zeile aufklappbar
|
||||||
|
* (#027). „Ältere laden“ haengt die naechste Seite an, statt die Liste zu ersetzen – wer
|
||||||
|
* eine Spur verfolgt, will das Neuere dabei nicht aus dem Blick verlieren.
|
||||||
|
*/
|
||||||
|
export function ProtokollListe({ seite, query }: { seite: ProtokollSeite; query: string }) {
|
||||||
|
const [zeilen, setZeilen] = useState(seite.zeilen);
|
||||||
|
const [weiterVor, setWeiterVor] = useState(seite.weiterVor);
|
||||||
|
const [offen, setOffen] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [laedt, starten] = useTransition();
|
||||||
|
|
||||||
|
const umschalten = (id: string) =>
|
||||||
|
setOffen((vorher) => {
|
||||||
|
const neu = new Set(vorher);
|
||||||
|
if (neu.has(id)) neu.delete(id);
|
||||||
|
else neu.add(id);
|
||||||
|
return neu;
|
||||||
|
});
|
||||||
|
|
||||||
|
const aeltereLaden = () => {
|
||||||
|
if (!weiterVor) return;
|
||||||
|
starten(async () => {
|
||||||
|
const antwort = await aeltereLadenAktion(query, weiterVor);
|
||||||
|
if ("fehler" in antwort) {
|
||||||
|
setFehler(antwort.fehler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFehler(undefined);
|
||||||
|
setZeilen((vorher) => [...vorher, ...antwort.zeilen]);
|
||||||
|
setWeiterVor(antwort.weiterVor);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-4">
|
||||||
|
{/* Unter md Karten statt der Tabelle: acht Spalten passen nicht auf 390 px. */}
|
||||||
|
<ul className="flex flex-col md:hidden" aria-label="Protokolleinträge">
|
||||||
|
{zeilen.map((z) => (
|
||||||
|
<li key={z.id} className="border-border border-b py-3 last:border-b-0">
|
||||||
|
<ZeileKompakt z={z} />
|
||||||
|
<details className="group mt-1">
|
||||||
|
<summary className="text-muted hover:text-text -mx-2 flex min-h-11 cursor-pointer list-none items-center gap-1.5 rounded-box px-2 text-[13px]">
|
||||||
|
<ChevronDown aria-hidden className="size-4 transition-transform group-open:rotate-180" />
|
||||||
|
Einzelheiten
|
||||||
|
</summary>
|
||||||
|
<Einzelheiten z={z} />
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Tabelle
|
||||||
|
className="hidden md:block"
|
||||||
|
beschriftung="Protokolleinträge"
|
||||||
|
spalten={[
|
||||||
|
{ titel: "Zeitpunkt" },
|
||||||
|
{ titel: "Aktion" },
|
||||||
|
{ titel: "Akteur" },
|
||||||
|
{ titel: "Host" },
|
||||||
|
{ titel: "Zielbenutzer" },
|
||||||
|
{ titel: "Fingerprint" },
|
||||||
|
{ titel: "Ergebnis" },
|
||||||
|
{ titel: "Einzelheiten", versteckt: true, className: "w-12" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{zeilen.map((z) => {
|
||||||
|
const istOffen = offen.has(z.id);
|
||||||
|
return <ZeilenPaar key={z.id} z={z} offen={istOffen} umschalten={() => umschalten(z.id)} />;
|
||||||
|
})}
|
||||||
|
</Tabelle>
|
||||||
|
|
||||||
|
{fehler && <p className="text-danger text-[13px]">{fehler}</p>}
|
||||||
|
{weiterVor && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button onClick={aeltereLaden} disabled={laedt} icon={<History className="size-4" />}>
|
||||||
|
{laedt ? "Lädt …" : "Ältere laden"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AktionZelle({ z }: { z: ProtokollZeile }) {
|
||||||
|
return (
|
||||||
|
<span title={z.aktion} className={clsx("font-medium", TON_FARBE[aktionTon(z.aktion, z.ergebnis)])}>
|
||||||
|
{aktionText(z.aktion)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErgebnisBadge({ ergebnis }: { ergebnis: ProtokollZeile["ergebnis"] }) {
|
||||||
|
return <Badge variante={ERGEBNIS_BADGE[ergebnis]}>{ERGEBNIS_TEXT[ergebnis]}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZeilenPaar({ z, offen, umschalten }: { z: ProtokollZeile; offen: boolean; umschalten: () => void }) {
|
||||||
|
const detailId = `eintrag-${z.id}-einzelheiten`;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TabellenZeile className={clsx(offen && "border-b-0")}>
|
||||||
|
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||||
|
{/* Anker fuer den Sprung aus der Kettenpruefung zur ersten fehlerhaften ID. */}
|
||||||
|
<span
|
||||||
|
id={`eintrag-${z.id}`}
|
||||||
|
className="target:text-danger target:outline-danger scroll-mt-24 target:outline target:outline-offset-4"
|
||||||
|
>
|
||||||
|
{protokollZeit(z.zeitpunkt)}
|
||||||
|
</span>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="min-w-[12rem]">
|
||||||
|
<AktionZelle z={z} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">{z.akteurName}</TabellenZelle>
|
||||||
|
<TabellenZelle mono className="whitespace-nowrap">
|
||||||
|
{z.hostName ?? <Leer />}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle mono className="whitespace-nowrap">
|
||||||
|
{z.zielbenutzer ?? <Leer />}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||||
|
{z.fingerprint ? <span title={z.fingerprint}>{fingerprintKurz(z.fingerprint)}</span> : <Leer />}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<ErgebnisBadge ergebnis={z.ergebnis} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle rechts className="w-12">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={umschalten}
|
||||||
|
aria-expanded={offen}
|
||||||
|
aria-controls={detailId}
|
||||||
|
aria-label={`Einzelheiten zu Eintrag ${z.id}`}
|
||||||
|
className="text-muted hover:text-text hover:bg-raised rounded-feld -my-2 inline-flex size-11 items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown aria-hidden className={clsx("size-4 transition-transform", offen && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
{offen && (
|
||||||
|
<tr id={detailId} className="border-border border-b last:border-b-0">
|
||||||
|
<td colSpan={8} className="pb-4">
|
||||||
|
<Einzelheiten z={z} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Leer() {
|
||||||
|
return <span className="text-muted">–</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kopf einer Karte in der mobilen Ansicht. */
|
||||||
|
function ZeileKompakt({ z }: { z: ProtokollZeile }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted font-mono text-[12.5px]">{protokollZeit(z.zeitpunkt)}</span>
|
||||||
|
<ErgebnisBadge ergebnis={z.ergebnis} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[14.5px] leading-snug">
|
||||||
|
<AktionZelle z={z} />
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-[13px] break-words">{z.akteurName}</span>
|
||||||
|
{(z.hostName || z.zielbenutzer) && (
|
||||||
|
<span className="text-text font-mono text-[12.5px] break-all">
|
||||||
|
{z.zielbenutzer ? `${z.zielbenutzer}@` : ""}
|
||||||
|
{z.hostName ?? "–"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{z.fingerprint && (
|
||||||
|
<span className="text-muted font-mono text-[12.5px] break-all">{fingerprintKurz(z.fingerprint)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Einzelheiten({ z }: { z: ProtokollZeile }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-sidebar border-border rounded-box flex min-w-0 flex-col gap-4 border p-4">
|
||||||
|
<dl className="grid min-w-0 gap-x-4 gap-y-2 text-[13px] sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||||
|
<dt className="text-muted">ID</dt>
|
||||||
|
<dd className="font-mono">{z.id}</dd>
|
||||||
|
<dt className="text-muted">Aktion (technisch)</dt>
|
||||||
|
<dd className="font-mono break-all">{z.aktion}</dd>
|
||||||
|
{z.fingerprint && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted">Fingerprint</dt>
|
||||||
|
<dd className="font-mono break-all">{z.fingerprint}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt className="text-muted">Hash</dt>
|
||||||
|
<dd className="font-mono break-all" data-feld="hash">
|
||||||
|
{z.hash}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted">Vorgänger-Hash</dt>
|
||||||
|
<dd className="font-mono break-all">{z.vorgaengerHash || "– (erster Eintrag)"}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{z.diff && (
|
||||||
|
<section className="flex min-w-0 flex-col gap-2" aria-label="Diff">
|
||||||
|
<h3 className="text-muted text-[13px] font-medium">Diff</h3>
|
||||||
|
<DiffBox zeilen={diffZeilen(z.diff)} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{z.details && (
|
||||||
|
<section className="flex min-w-0 flex-col gap-2" aria-label="Details">
|
||||||
|
<h3 className="text-muted text-[13px] font-medium">Details</h3>
|
||||||
|
<dl className="border-border bg-bg rounded-box grid min-w-0 gap-x-4 gap-y-1.5 border p-3 text-[12.5px] sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)]">
|
||||||
|
{z.details.map((d) => (
|
||||||
|
<div key={d.name} className="contents">
|
||||||
|
<dt className="text-muted font-mono break-all">{d.name}</dt>
|
||||||
|
<dd className="text-text mb-1.5 font-mono break-all whitespace-pre-wrap sm:mb-0">{d.wert}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Ban, CircleCheck, CircleX, KeyRound, PlugZap } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { Button, Formularfehler, Karte } from "@/components/ui";
|
||||||
|
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||||
|
import { warten } from "@/components/vorgang/warten";
|
||||||
|
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||||
|
import { RotationAbbrechen } from "./RotationAbbrechen";
|
||||||
|
import {
|
||||||
|
rotationAltWiderrufenAktion,
|
||||||
|
rotationStartenAktion,
|
||||||
|
rotationUebernehmenAktion,
|
||||||
|
} from "@/server/actions/rotation";
|
||||||
|
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
|
||||||
|
import type { OffeneEntfernung } from "@/server/services/widerruf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gefuehrte Rotation (#023) als Schrittfolge wie der Assistent (#020). Der Schritt kommt vom
|
||||||
|
* Server (aus der Datenbank errechnet) – nach Neuladen, Abbruch oder Fehlschlag geht es an
|
||||||
|
* derselben Stelle weiter.
|
||||||
|
*/
|
||||||
|
const SCHRITTE = [
|
||||||
|
"Neuer Schlüssel",
|
||||||
|
"Zuweisungen übernehmen",
|
||||||
|
"Dry-Run & Ausrollen",
|
||||||
|
"Verbindungstests",
|
||||||
|
"Alten widerrufen",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type RotationsTest = {
|
||||||
|
id: string;
|
||||||
|
ziel: string;
|
||||||
|
ausgerollt: boolean;
|
||||||
|
status: string | null;
|
||||||
|
fehler: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RotationsDaten = {
|
||||||
|
alt: { id: string; name: string };
|
||||||
|
neu: { id: string; name: string } | null;
|
||||||
|
schritt: 0 | 1 | 2 | 3 | 4 | 5;
|
||||||
|
neuOffen: number;
|
||||||
|
tests: RotationsTest[];
|
||||||
|
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
|
||||||
|
widerruf: OffeneEntfernung[] | null;
|
||||||
|
darfPrivat: boolean;
|
||||||
|
/**
|
||||||
|
* Abgebrochene Rotation (#032): `offen` sind die offenen Entfernungen des neuen Schluessels,
|
||||||
|
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
|
||||||
|
*/
|
||||||
|
abbruch: { offen: OffeneEntfernung[] | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Rotation({ daten }: { daten: RotationsDaten }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [laeuft, setLaeuft] = useState<string>();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const { alt, neu, schritt } = daten;
|
||||||
|
|
||||||
|
async function kette(arbeit: () => Promise<string | undefined>) {
|
||||||
|
setFehler(undefined);
|
||||||
|
try {
|
||||||
|
const meldung = await arbeit();
|
||||||
|
if (meldung) setFehler(meldung);
|
||||||
|
} catch (f) {
|
||||||
|
setFehler((f as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLaeuft(undefined);
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorbereiten = () =>
|
||||||
|
kette(async () => {
|
||||||
|
if (!neu) {
|
||||||
|
setLaeuft("Neues Schlüsselpaar wird erzeugt …");
|
||||||
|
const antwort = await rotationStartenAktion(alt.id);
|
||||||
|
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
|
||||||
|
const ende = await warten(antwort.vorgangId);
|
||||||
|
if (ende.status !== "ERLEDIGT") return ende.fehler ?? "Erzeugen fehlgeschlagen.";
|
||||||
|
}
|
||||||
|
setLaeuft("Zuweisungen werden übernommen …");
|
||||||
|
return (await rotationUebernehmenAktion(alt.id)).fehler;
|
||||||
|
});
|
||||||
|
|
||||||
|
const testen = () =>
|
||||||
|
kette(async () => {
|
||||||
|
for (const test of daten.tests) {
|
||||||
|
setLaeuft(`Teste ${test.ziel} …`);
|
||||||
|
const antwort = await verbindungTestenAktion(test.id);
|
||||||
|
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
|
||||||
|
await warten(antwort.vorgangId);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const altWiderrufen = () =>
|
||||||
|
kette(async () => {
|
||||||
|
setLaeuft("Alter Schlüssel wird zum Entfernen vorgemerkt …");
|
||||||
|
return (await rotationAltWiderrufenAktion(alt.id)).fehler;
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = (
|
||||||
|
<>
|
||||||
|
<Formularfehler text={fehler} />
|
||||||
|
{laeuft && (
|
||||||
|
<p role="status" className="text-muted text-[14px]">
|
||||||
|
{laeuft}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (daten.abbruch && neu) {
|
||||||
|
return daten.abbruch.offen ? (
|
||||||
|
<Karte
|
||||||
|
titel="Rotation wird abgebrochen"
|
||||||
|
untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`}
|
||||||
|
className="border-danger-border"
|
||||||
|
>
|
||||||
|
<WiderrufAblauf
|
||||||
|
schluesselIds={[neu.id]}
|
||||||
|
offen={daten.abbruch.offen}
|
||||||
|
zielkonten={[]}
|
||||||
|
vorauswahl={[]}
|
||||||
|
ruecksprung={`/schluessel/${alt.id}/rotieren`}
|
||||||
|
darfPrivat={daten.darfPrivat}
|
||||||
|
ohneZielkonten
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
) : (
|
||||||
|
<Karte titel="Rotation abgebrochen">
|
||||||
|
<p className="text-[14px]">
|
||||||
|
Der neue Schlüssel{" "}
|
||||||
|
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
|
||||||
|
{neu.name}
|
||||||
|
</Link>{" "}
|
||||||
|
ist widerrufen und von allen Hosts entfernt.{" "}
|
||||||
|
<Link href={`/schluessel/${alt.id}`} className="text-accent hover:underline">
|
||||||
|
{alt.name}
|
||||||
|
</Link>{" "}
|
||||||
|
bleibt aktiv.
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
|
||||||
|
|
||||||
|
{schritt <= 1 && (
|
||||||
|
<Karte
|
||||||
|
titel={schritt === 0 ? "Neuen Schlüssel erzeugen" : "Zuweisungen übernehmen"}
|
||||||
|
untertitel="Noch ändert sich auf keinem Host etwas."
|
||||||
|
>
|
||||||
|
<ul className="text-muted mb-4 list-disc space-y-1 pl-5 text-[14px]">
|
||||||
|
<li>Neues Schlüsselpaar gleichen Typs, Name mit „(rotiert …)“, verknüpft mit dem alten.</li>
|
||||||
|
<li>Public Key: dieselben Ziele und Einschränkungen wie bisher.</li>
|
||||||
|
<li>
|
||||||
|
Private Key: derselbe Host und Benutzer, neue Datei mit Suffix <code>-rJJJJMMTT</code>; der
|
||||||
|
config-Eintrag behält seinen Alias und zeigt danach auf die neue Datei.
|
||||||
|
</li>
|
||||||
|
<li>Der alte Schlüssel bleibt gültig, bis der neue getestet ist.</li>
|
||||||
|
</ul>
|
||||||
|
{status}
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<KeyRound className="size-4" />}
|
||||||
|
disabled={Boolean(laeuft)}
|
||||||
|
onClick={() => void vorbereiten()}
|
||||||
|
>
|
||||||
|
{neu ? "Zuweisungen übernehmen" : "Neuen Schlüssel erzeugen und Zuweisungen übernehmen"}
|
||||||
|
</Button>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 2 && neu && (
|
||||||
|
<Karte
|
||||||
|
titel="Neuen Schlüssel ausrollen"
|
||||||
|
untertitel="Dry-Run prüfen, dann anwenden – der alte Schlüssel bleibt vorerst daneben bestehen."
|
||||||
|
>
|
||||||
|
<AusrollenPanel auswahl={{ schluesselId: neu.id }} offen={daten.neuOffen} />
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 3 && (
|
||||||
|
<Karte
|
||||||
|
titel="Verbindungen mit dem neuen Schlüssel testen"
|
||||||
|
untertitel="Erst wenn alle Tests grün sind, wird der alte Schlüssel entfernt."
|
||||||
|
>
|
||||||
|
<ul className="mb-4 flex flex-col gap-2">
|
||||||
|
{daten.tests.map((t) => (
|
||||||
|
<li key={t.id} className="flex flex-col gap-0.5 text-[14px]">
|
||||||
|
<span className="flex items-center gap-2 font-mono text-[13px] break-all">
|
||||||
|
{t.status === "ERLEDIGT" ? (
|
||||||
|
<CircleCheck className="text-ok size-4 shrink-0" aria-label="grün" />
|
||||||
|
) : t.status === "FEHLGESCHLAGEN" ? (
|
||||||
|
<CircleX className="text-danger size-4 shrink-0" aria-label="fehlgeschlagen" />
|
||||||
|
) : (
|
||||||
|
<PlugZap className="text-muted size-4 shrink-0" aria-label="noch nicht getestet" />
|
||||||
|
)}
|
||||||
|
{t.ziel}
|
||||||
|
</span>
|
||||||
|
{t.fehler && <span className="text-danger pl-6 text-[13px]">{t.fehler}</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{status}
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<PlugZap className="size-4" />}
|
||||||
|
disabled={Boolean(laeuft)}
|
||||||
|
onClick={() => void testen()}
|
||||||
|
>
|
||||||
|
Verbindungen testen
|
||||||
|
</Button>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 4 && !daten.widerruf && (
|
||||||
|
<Karte
|
||||||
|
titel="Alten Schlüssel entfernen"
|
||||||
|
untertitel={
|
||||||
|
daten.tests.length > 0
|
||||||
|
? "Alle Verbindungstests sind grün."
|
||||||
|
: "Keine Private-Key-Zuweisung mit Alias – es gibt nichts zu testen."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-muted mb-4 text-[14px]">
|
||||||
|
Der alte Schlüssel „{alt.name}“ wird wie bei einem Widerruf von allen Hosts entfernt (Dry-Run
|
||||||
|
folgt). Danach gilt er als rotiert.
|
||||||
|
</p>
|
||||||
|
{status}
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Ban className="size-4" />}
|
||||||
|
disabled={Boolean(laeuft)}
|
||||||
|
onClick={() => void altWiderrufen()}
|
||||||
|
>
|
||||||
|
Alten Schlüssel widerrufen
|
||||||
|
</Button>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 4 && daten.widerruf && (
|
||||||
|
<Karte
|
||||||
|
titel="Alten Schlüssel entfernen"
|
||||||
|
untertitel="Der Dry-Run zeigt, was vom alten Schlüssel verschwindet."
|
||||||
|
className="border-danger-border"
|
||||||
|
>
|
||||||
|
<WiderrufAblauf
|
||||||
|
schluesselIds={[alt.id]}
|
||||||
|
offen={daten.widerruf}
|
||||||
|
zielkonten={[]}
|
||||||
|
vorauswahl={[]}
|
||||||
|
ruecksprung={`/schluessel/${alt.id}/rotieren`}
|
||||||
|
darfPrivat={daten.darfPrivat}
|
||||||
|
ohneZielkonten
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Abbrechen geht, solange der alte Schluessel noch nicht entfernt wird (#032). */}
|
||||||
|
{neu && schritt >= 1 && schritt <= 4 && !daten.widerruf && (
|
||||||
|
<RotationAbbrechen altId={alt.id} altName={alt.name} neuName={neu.name} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schritt === 5 && neu && (
|
||||||
|
<Karte titel="Rotation abgeschlossen">
|
||||||
|
<p className="text-[14px]">
|
||||||
|
Der alte Schlüssel ist von allen Hosts entfernt. Weiter mit{" "}
|
||||||
|
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
|
||||||
|
{neu.name}
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Undo2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Button, Formularfehler } from "@/components/ui";
|
||||||
|
import { rotationAbbrechenAktion } from "@/server/actions/rotation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Rotation abbrechen“ (#032) – auf der Rotationsseite und auf der Seite des Nachfolgers. Der
|
||||||
|
* Klick merkt den neuen Schluessel nur zum Widerruf vor; entfernt wird er erst nach Dry-Run und
|
||||||
|
* „Anwenden“. Deshalb erst eine Rueckfrage, die genau das sagt.
|
||||||
|
*/
|
||||||
|
export function RotationAbbrechen({
|
||||||
|
altId,
|
||||||
|
altName,
|
||||||
|
neuName,
|
||||||
|
}: {
|
||||||
|
altId: string;
|
||||||
|
altName: string;
|
||||||
|
neuName: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
|
||||||
|
if (!offen) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button icon={<Undo2 className="size-4" />} onClick={() => setOffen(true)}>
|
||||||
|
Rotation abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Rotation abbrechen?"
|
||||||
|
className="border-danger-border bg-bg rounded-box flex flex-col gap-3 border px-4 py-3 text-[14px]"
|
||||||
|
>
|
||||||
|
<p className="font-medium">Rotation abbrechen?</p>
|
||||||
|
<ul className="text-muted list-disc space-y-1 pl-5 text-[13.5px]">
|
||||||
|
<li>
|
||||||
|
Der neue Schlüssel „{neuName}“ wird widerrufen – wo er schon ausgerollt ist, zeigt der Dry-Run, was
|
||||||
|
entfernt wird.
|
||||||
|
</li>
|
||||||
|
<li>„{altName}“ bleibt aktiv und unverändert; ein config-Alias zeigt danach wieder auf ihn.</li>
|
||||||
|
<li>Die Verknüpfung bleibt in der Rotationskette sichtbar, der Abbruch steht im Protokoll.</li>
|
||||||
|
</ul>
|
||||||
|
<Formularfehler text={fehler} />
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
<Button disabled={laeuft} onClick={() => setOffen(false)}>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Undo2 className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
const antwort = await rotationAbbrechenAktion(altId);
|
||||||
|
if (antwort.fehler) {
|
||||||
|
setFehler(antwort.fehler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOffen(false);
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laeuft ? "Wird vorgemerkt …" : "Neuen Schlüssel widerrufen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export function Karte({
|
|||||||
className,
|
className,
|
||||||
innen = "normal",
|
innen = "normal",
|
||||||
as: Element = "section",
|
as: Element = "section",
|
||||||
|
id,
|
||||||
}: {
|
}: {
|
||||||
titel?: ReactNode;
|
titel?: ReactNode;
|
||||||
untertitel?: ReactNode;
|
untertitel?: ReactNode;
|
||||||
@@ -22,9 +23,12 @@ export function Karte({
|
|||||||
/** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */
|
/** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */
|
||||||
innen?: "normal" | "ohne";
|
innen?: "normal" | "ohne";
|
||||||
as?: "section" | "div" | "article";
|
as?: "section" | "div" | "article";
|
||||||
|
/** Sprungziel für Links von anderen Seiten (z. B. `#dry-run`, #032). */
|
||||||
|
id?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Element
|
<Element
|
||||||
|
id={id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-border bg-surface min-w-0 rounded-karte border",
|
"border-border bg-surface min-w-0 rounded-karte border",
|
||||||
innen === "normal" && "p-5 sm:p-6",
|
innen === "normal" && "p-5 sm:p-6",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schrittanzeige fuer gefuehrte Ablaeufe (Assistent #020, Widerruf #024, Rotation #023):
|
||||||
|
* erledigte Schritte mit Haken, der aktuelle in Akzentfarbe und als `aria-current="step"`.
|
||||||
|
*/
|
||||||
|
export function Schrittleiste({
|
||||||
|
schritte,
|
||||||
|
aktuell,
|
||||||
|
beschriftung = "Schritte",
|
||||||
|
}: {
|
||||||
|
schritte: readonly string[];
|
||||||
|
/** Index des aktuellen Schritts; `schritte.length` heisst: alles erledigt. */
|
||||||
|
aktuell: number;
|
||||||
|
beschriftung?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ol className="flex flex-wrap gap-2" aria-label={beschriftung}>
|
||||||
|
{schritte.map((titel, index) => (
|
||||||
|
<li
|
||||||
|
key={titel}
|
||||||
|
aria-current={index === aktuell ? "step" : undefined}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
|
||||||
|
index === aktuell
|
||||||
|
? "border-accent text-accent bg-raised font-medium"
|
||||||
|
: index < aktuell
|
||||||
|
? "border-border text-text"
|
||||||
|
: "border-border text-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-mono">
|
||||||
|
{index < aktuell ? <Check className="size-4" aria-hidden /> : index + 1}
|
||||||
|
</span>
|
||||||
|
{titel}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Wartet auf das Ende eines Vorgangs – fuer Ketten im Klick (Assistent #020, Widerruf #024,
|
||||||
|
* Rotation #023), nicht als Hook.
|
||||||
|
*/
|
||||||
|
export type VorgangsEnde = { status: string; fehler: string | null; ergebnis: unknown };
|
||||||
|
|
||||||
|
export async function warten(vorgangId: string): Promise<VorgangsEnde> {
|
||||||
|
for (;;) {
|
||||||
|
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||||
|
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
|
||||||
|
const stand = (await antwort.json()) as VorgangsEnde;
|
||||||
|
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
|
||||||
|
await new Promise((weiter) => setTimeout(weiter, 800));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Archive } from "lucide-react";
|
||||||
|
import { useActionState, useRef } from "react";
|
||||||
|
import { Button, Formularfehler } from "@/components/ui";
|
||||||
|
import type { FormularZustand } from "@/server/actions/formular";
|
||||||
|
import { vorlageArchivierenAktion } from "@/server/actions/vorlagen";
|
||||||
|
|
||||||
|
/** Archivieren mit Rueckfrage (#019), gleiches Muster wie bei Hosts (#008). */
|
||||||
|
export function VorlageArchivierenDialog({ vorlageId, name }: { vorlageId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(vorlageArchivierenAktion, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Archive className="size-4" />}
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
>
|
||||||
|
Vorlage archivieren
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-labelledby="archivieren-titel"
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<form action={aktion} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="id" value={vorlageId} />
|
||||||
|
<h2 id="archivieren-titel" className="text-[17px] font-semibold">
|
||||||
|
„{name}“ archivieren?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
Die Vorlage steht im Assistenten nicht mehr zur Auswahl. Schlüssel, die mit ihr angelegt wurden,
|
||||||
|
bleiben unverändert; das Protokoll bleibt erhalten.
|
||||||
|
</p>
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||||
|
<Button type="submit" variante="gefahr" disabled={laeuft}>
|
||||||
|
{laeuft ? "Archiviere …" : "Archivieren"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Karte, Textbereich } from "@/components/ui";
|
||||||
|
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||||||
|
import { optionenPruefen } from "@/lib/ssh/optionen";
|
||||||
|
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
|
||||||
|
import { PLATZHALTER, platzhalterErsetzen, type PlatzhalterWerte } from "@/lib/vorlagen";
|
||||||
|
import { vorlageSpeichernAktion } from "@/server/actions/vorlagen";
|
||||||
|
|
||||||
|
type SudoModus = "KEINER" | "BEFEHLE" | "VOLL";
|
||||||
|
|
||||||
|
export type VorlageFormularVorgabe = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
beschreibung: string;
|
||||||
|
schluesselTyp: "ED25519" | "RSA4096";
|
||||||
|
kategorie: "DIENST" | "PERSON";
|
||||||
|
standardLaufzeitTage: number | null;
|
||||||
|
zielbenutzer: string;
|
||||||
|
zielbenutzerAnlegen: boolean;
|
||||||
|
sudoModus: SudoModus;
|
||||||
|
sudoBefehle: string[];
|
||||||
|
authorizedKeysOptionen: string;
|
||||||
|
sshConfigAlias: string;
|
||||||
|
knownHostsEintragen: boolean;
|
||||||
|
privatNachAusrollenLoeschen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LEERE_VORLAGE: VorlageFormularVorgabe = {
|
||||||
|
name: "",
|
||||||
|
beschreibung: "",
|
||||||
|
schluesselTyp: "ED25519",
|
||||||
|
kategorie: "DIENST",
|
||||||
|
standardLaufzeitTage: null,
|
||||||
|
zielbenutzer: "",
|
||||||
|
zielbenutzerAnlegen: false,
|
||||||
|
sudoModus: "KEINER",
|
||||||
|
sudoBefehle: [],
|
||||||
|
authorizedKeysOptionen: "",
|
||||||
|
sshConfigAlias: "",
|
||||||
|
knownHostsEintragen: true,
|
||||||
|
privatNachAusrollenLoeschen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Beispielwerte der Vorschau – sichtbar andere als die Probewerte des Servers. */
|
||||||
|
const BEISPIEL: Required<PlatzhalterWerte> = {
|
||||||
|
quellHost: "srv-build01",
|
||||||
|
quellHostIp: "10.0.0.5",
|
||||||
|
zielHost: "srv-web01",
|
||||||
|
zielHostIp: "10.0.0.20",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Vorschau = { wert: string | null; fehler?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Vorschau ist nur Anzeige (#019): Fehler werden zum Hinweis, nicht zum Absturz – die
|
||||||
|
* verbindliche Pruefung macht der Server beim Speichern mit denselben Funktionen.
|
||||||
|
*/
|
||||||
|
function vorschau(berechnen: () => string | null): Vorschau {
|
||||||
|
try {
|
||||||
|
return { wert: berechnen() };
|
||||||
|
} catch (fehler) {
|
||||||
|
return { wert: null, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function befehlsZeilen(text: string): string[] {
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((b) => b.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Vorschauzeile({ titel, ergebnis, leer }: { titel: string; ergebnis: Vorschau; leer: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||||
|
<dd className="min-w-0">
|
||||||
|
{ergebnis.fehler ? (
|
||||||
|
<span className="text-danger text-[13.5px]">{ergebnis.fehler}</span>
|
||||||
|
) : ergebnis.wert ? (
|
||||||
|
<code className="text-text font-mono text-[13px] break-all">{ergebnis.wert}</code>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted text-[13.5px]">{leer}</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formular fuer neue und bestehende Vorlagen (#019). Regeln prueft der Server. */
|
||||||
|
export function VorlagenFormular({ vorgabe }: { vorgabe: VorlageFormularVorgabe }) {
|
||||||
|
const bearbeiten = Boolean(vorgabe.id);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(vorlageSpeichernAktion);
|
||||||
|
const f = zustand.feldfehler ?? {};
|
||||||
|
|
||||||
|
// Kontrolliert nur, was die Vorschau braucht; der Rest bleibt unkontrolliert.
|
||||||
|
const [zielbenutzer, setZielbenutzer] = useState(vorgabe.zielbenutzer);
|
||||||
|
const [sudoModus, setSudoModus] = useState<SudoModus>(vorgabe.sudoModus);
|
||||||
|
const [befehle, setBefehle] = useState(vorgabe.sudoBefehle.join("\n"));
|
||||||
|
const [optionen, setOptionen] = useState(vorgabe.authorizedKeysOptionen);
|
||||||
|
const [alias, setAlias] = useState(vorgabe.sshConfigAlias);
|
||||||
|
|
||||||
|
const optionenVorschau = vorschau(() =>
|
||||||
|
optionen.trim() ? optionenPruefen(platzhalterErsetzen(optionen.trim(), BEISPIEL)) || null : null,
|
||||||
|
);
|
||||||
|
const aliasVorschau = vorschau(() => (alias.trim() ? platzhalterErsetzen(alias.trim(), BEISPIEL) : null));
|
||||||
|
const sudoersVorschau = vorschau(() =>
|
||||||
|
sudoModus === "KEINER" ? null : sudoersInhalt(zielbenutzer.trim(), sudoModus, befehlsZeilen(befehle)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||||
|
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
|
||||||
|
<Karte titel="Allgemein">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={vorgabe.name}
|
||||||
|
required
|
||||||
|
maxLength={80}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="z. B. Monitoring-Zugang"
|
||||||
|
fehler={f["name"]}
|
||||||
|
/>
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Beschreibung (optional)"
|
||||||
|
name="beschreibung"
|
||||||
|
defaultValue={vorgabe.beschreibung}
|
||||||
|
maxLength={500}
|
||||||
|
fehler={f["beschreibung"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Schlüssel">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Feldgruppe legende="Typ" fehler={f["schluesselTyp"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="schluesselTyp"
|
||||||
|
value="ED25519"
|
||||||
|
defaultChecked={vorgabe.schluesselTyp === "ED25519"}
|
||||||
|
beschriftung={<span className="font-mono">ed25519</span>}
|
||||||
|
erklaerung="Empfohlen – kurz, schnell, sicher."
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="schluesselTyp"
|
||||||
|
value="RSA4096"
|
||||||
|
defaultChecked={vorgabe.schluesselTyp === "RSA4096"}
|
||||||
|
beschriftung={<span className="font-mono">rsa-4096</span>}
|
||||||
|
erklaerung="Nur für alte Gegenstellen ohne ed25519."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="kategorie"
|
||||||
|
value="DIENST"
|
||||||
|
defaultChecked={vorgabe.kategorie === "DIENST"}
|
||||||
|
beschriftung="Dienst"
|
||||||
|
erklaerung="Host-zu-Host oder Automatisierung; sshkv verwahrt den Private Key."
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="kategorie"
|
||||||
|
value="PERSON"
|
||||||
|
defaultChecked={vorgabe.kategorie === "PERSON"}
|
||||||
|
beschriftung="Person"
|
||||||
|
erklaerung="Der Private Key bleibt bei der Person; kein Benutzer anlegen, kein sudo."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Standard-Laufzeit in Tagen (optional)"
|
||||||
|
name="standardLaufzeitTage"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={3650}
|
||||||
|
defaultValue={vorgabe.standardLaufzeitTage ?? ""}
|
||||||
|
hinweis="Leer lassen für Schlüssel ohne Ablaufdatum."
|
||||||
|
fehler={f["standardLaufzeitTage"]}
|
||||||
|
className="sm:max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="Zielkonto" untertitel="Unter welchem Benutzer der Schlüssel auf dem Zielhost landet">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zielbenutzer"
|
||||||
|
name="zielbenutzer"
|
||||||
|
value={zielbenutzer}
|
||||||
|
onChange={(e) => setZielbenutzer(e.target.value)}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
maxLength={32}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="deploy"
|
||||||
|
hinweis="Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen."
|
||||||
|
fehler={f["zielbenutzer"]}
|
||||||
|
className="sm:max-w-xs"
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="zielbenutzerAnlegen"
|
||||||
|
value="ja"
|
||||||
|
defaultChecked={vorgabe.zielbenutzerAnlegen}
|
||||||
|
beschriftung="Benutzer anlegen, falls er fehlt"
|
||||||
|
erklaerung="sshkv legt ihn beim Ausrollen an und entfernt ihn beim Widerruf wieder."
|
||||||
|
/>
|
||||||
|
<Feldgruppe
|
||||||
|
legende="sudo-Rechte"
|
||||||
|
hinweis="Nur für Benutzer, die sshkv selbst anlegt."
|
||||||
|
fehler={f["sudoModus"]}
|
||||||
|
>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-3">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["KEINER", "keine"],
|
||||||
|
["BEFEHLE", "bestimmte Befehle"],
|
||||||
|
["VOLL", "voll"],
|
||||||
|
] as const
|
||||||
|
).map(([wert, text]) => (
|
||||||
|
<Ankreuzfeld
|
||||||
|
key={wert}
|
||||||
|
type="radio"
|
||||||
|
name="sudoModus"
|
||||||
|
value={wert}
|
||||||
|
checked={sudoModus === wert}
|
||||||
|
onChange={() => setSudoModus(wert)}
|
||||||
|
beschriftung={text}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
{/* Ausgeblendet statt entfernt, damit die Befehle beim Hin- und Herschalten bleiben. */}
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Erlaubte Befehle"
|
||||||
|
name="sudoBefehle"
|
||||||
|
value={befehle}
|
||||||
|
onChange={(e) => setBefehle(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={"/usr/bin/systemctl restart nginx\n/usr/bin/systemctl reload nginx"}
|
||||||
|
hinweis="Ein Befehl je Zeile, mit absolutem Pfad. Argumente sind erlaubt, Platzhalter nicht."
|
||||||
|
fehler={f["sudoBefehle"]}
|
||||||
|
className={sudoModus === "BEFEHLE" ? "[&_textarea]:font-mono [&_textarea]:text-[13px]" : "hidden"}
|
||||||
|
/>
|
||||||
|
{sudoModus === "VOLL" && (
|
||||||
|
<p
|
||||||
|
role="note"
|
||||||
|
className="border-danger-border bg-danger-soft text-danger rounded-box flex items-start gap-2.5 border px-3 py-2.5 text-[14px]"
|
||||||
|
>
|
||||||
|
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
|
<span>
|
||||||
|
Voller sudo-Zugang ohne Passwort: Wer diesen Schlüssel hat, ist auf dem Zielhost faktisch{" "}
|
||||||
|
<strong className="font-semibold">root</strong>. Nur wählen, wenn bestimmte Befehle nicht
|
||||||
|
reichen.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte titel="authorized_keys & ssh">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Feld
|
||||||
|
beschriftung="authorized_keys-Optionen (optional)"
|
||||||
|
name="authorizedKeysOptionen"
|
||||||
|
value={optionen}
|
||||||
|
onChange={(e) => setOptionen(e.target.value)}
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={'from="{{quellHostIp}}",no-port-forwarding'}
|
||||||
|
fehler={f["authorizedKeysOptionen"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Alias-Muster für ~/.ssh/config (optional)"
|
||||||
|
name="sshConfigAlias"
|
||||||
|
value={alias}
|
||||||
|
onChange={(e) => setAlias(e.target.value)}
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="deploy-{{zielHost}}"
|
||||||
|
fehler={f["sshConfigAlias"]}
|
||||||
|
/>
|
||||||
|
<div className="border-border bg-sidebar rounded-box text-muted border px-4 py-3 text-[13px]">
|
||||||
|
<p>
|
||||||
|
Platzhalter:{" "}
|
||||||
|
{PLATZHALTER.map((p, i) => (
|
||||||
|
<span key={p}>
|
||||||
|
{i > 0 && ", "}
|
||||||
|
<code className="text-text font-mono whitespace-nowrap">{`{{${p}}}`}</code>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
. Beim Ausrollen setzt sshkv Name und IP von Quell- und Zielhost ein.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5">
|
||||||
|
Beispiel: <code className="text-text font-mono break-all">{'from="{{quellHostIp}}"'}</code> wird
|
||||||
|
zu <code className="text-text font-mono break-all">{`from="${BEISPIEL.quellHostIp}"`}</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="knownHostsEintragen"
|
||||||
|
value="ja"
|
||||||
|
defaultChecked={vorgabe.knownHostsEintragen}
|
||||||
|
beschriftung="Host-Key des Ziels in known_hosts der Quelle eintragen"
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
name="privatNachAusrollenLoeschen"
|
||||||
|
value="ja"
|
||||||
|
defaultChecked={vorgabe.privatNachAusrollenLoeschen}
|
||||||
|
beschriftung="Private Key nach dem Ausrollen in sshkv löschen"
|
||||||
|
erklaerung="Dann liegt er nur noch auf dem Quellhost und lässt sich nicht erneut ausrollen."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Vorschau"
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
Mit Beispielwerten: Quelle <span className="font-mono">{BEISPIEL.quellHost}</span> (
|
||||||
|
<span className="font-mono">{BEISPIEL.quellHostIp}</span>), Ziel{" "}
|
||||||
|
<span className="font-mono">{BEISPIEL.zielHost}</span> (
|
||||||
|
<span className="font-mono">{BEISPIEL.zielHostIp}</span>)
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div aria-live="polite" className="flex flex-col gap-4">
|
||||||
|
<dl className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Vorschauzeile titel="authorized_keys-Optionen" ergebnis={optionenVorschau} leer="keine" />
|
||||||
|
<Vorschauzeile titel="Alias" ergebnis={aliasVorschau} leer="kein Eintrag in ~/.ssh/config" />
|
||||||
|
</dl>
|
||||||
|
{sudoModus !== "KEINER" &&
|
||||||
|
(sudoersVorschau.fehler ? (
|
||||||
|
<p className="text-danger text-[13.5px]">sudoers: {sudoersVorschau.fehler}</p>
|
||||||
|
) : (
|
||||||
|
sudoersVorschau.wert && (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<p className="text-muted text-[13px] font-medium">
|
||||||
|
<span className="font-mono">/etc/sudoers.d/sshkv-{zielbenutzer.trim()}</span>
|
||||||
|
</p>
|
||||||
|
{/* Ohne Umbruch: sudoers ist zeilenbasiert, ein optischer Umbruch taeuschte eine
|
||||||
|
zweite Regel vor (#019). Der Block scrollt dann fuer sich. */}
|
||||||
|
<MonoBlock wert={sudoersVorschau.wert} beschriftung="sudoers-Inhalt" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Vorlage anlegen"}
|
||||||
|
</Button>
|
||||||
|
<Button href="/vorlagen">Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Alle auswählen“ fuer den Massenwiderruf (#024): setzt alle aktivierbaren Kaestchen, die zum
|
||||||
|
* Formular gehoeren. Die Auswahl selbst bleibt ein schlichtes GET-Formular – ohne JavaScript
|
||||||
|
* waehlt man eben einzeln.
|
||||||
|
*/
|
||||||
|
export function AlleAuswaehlen({ formular }: { formular: string }) {
|
||||||
|
return (
|
||||||
|
<label className="hover:bg-raised/60 rounded-box flex min-h-11 cursor-pointer items-center gap-3 px-2 text-[14px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="accent-accent size-[18px] cursor-pointer"
|
||||||
|
onChange={(e) => {
|
||||||
|
const kaestchen = document.querySelectorAll<HTMLInputElement>(
|
||||||
|
`input[type="checkbox"][form="${formular}"]:not(:disabled)`,
|
||||||
|
);
|
||||||
|
for (const k of kaestchen) k.checked = e.target.checked;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Alle auswählen
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCheck, ExternalLink } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
|
||||||
|
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||||
|
import { warten } from "@/components/vorgang/warten";
|
||||||
|
import { widerrufAbschliessenAktion, widerrufZielkontenAktion } from "@/server/actions/widerruf";
|
||||||
|
import type { OffeneEntfernung, ZielkontoKandidat } from "@/server/services/widerruf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein eingeleiteter Widerruf (#024), wiederaufnehmbar wie der Assistent (#020): Dry-Run und
|
||||||
|
* Anwenden fuer alle offenen Entfernungen, dann optional Zielkonten, dann Abschluss.
|
||||||
|
*
|
||||||
|
* Was noch fehlt, kommt immer frisch vom Server (`offen`) – nach einem Neuladen, einem
|
||||||
|
* Fehlschlag auf einem Host oder einer erneuten Bestaetigung geht es an derselben Stelle weiter.
|
||||||
|
*/
|
||||||
|
const SCHRITTE = ["Vorgemerkt", "Dry-Run & Anwenden", "Zielkonten", "Abgeschlossen"] as const;
|
||||||
|
|
||||||
|
const ZUSTAND_TEXT: Record<string, string> = {
|
||||||
|
ENTFERNEN: "steht aus",
|
||||||
|
FEHLGESCHLAGEN: "letzter Versuch fehlgeschlagen",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WiderrufAblauf({
|
||||||
|
schluesselIds,
|
||||||
|
offen,
|
||||||
|
zielkonten,
|
||||||
|
vorauswahl,
|
||||||
|
ruecksprung,
|
||||||
|
darfPrivat,
|
||||||
|
ohneZielkonten = false,
|
||||||
|
nachAbschluss,
|
||||||
|
}: {
|
||||||
|
schluesselIds: string[];
|
||||||
|
offen: OffeneEntfernung[];
|
||||||
|
zielkonten: ZielkontoKandidat[];
|
||||||
|
vorauswahl: string[];
|
||||||
|
/** Diese Seite – Ziel der erneuten Bestaetigung. */
|
||||||
|
ruecksprung: string;
|
||||||
|
darfPrivat: boolean;
|
||||||
|
/** Rotation (#023): das Zielkonto nutzt der Nachfolger weiter. */
|
||||||
|
ohneZielkonten?: boolean;
|
||||||
|
nachAbschluss?: () => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const waehlbar = ohneZielkonten ? [] : zielkonten.filter((k) => k.nochGenutztVon.length === 0);
|
||||||
|
// Ohne waehlbares Zielkonto entfaellt der Schritt ganz – ein leerer Schritt verwirrt nur.
|
||||||
|
const ohneSchritt = waehlbar.length === 0;
|
||||||
|
const [auswahl, setAuswahl] = useState<string[]>(() =>
|
||||||
|
vorauswahl.filter((id) => waehlbar.some((k) => k.id === id)),
|
||||||
|
);
|
||||||
|
const [zielkontenFertig, setZielkontenFertig] = useState(false);
|
||||||
|
const [laeuft, setLaeuft] = useState<string>();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [bestaetigen, setBestaetigen] = useState(false);
|
||||||
|
const [noch, setNoch] = useState<OffeneEntfernung[]>();
|
||||||
|
|
||||||
|
const aktuell = offen.length > 0 ? 1 : auswahl.length > 0 && !zielkontenFertig ? 2 : 3;
|
||||||
|
const privatOhneRecht = !darfPrivat && offen.some((o) => o.art === "privat");
|
||||||
|
|
||||||
|
async function abschliessen() {
|
||||||
|
setFehler(undefined);
|
||||||
|
setBestaetigen(false);
|
||||||
|
setNoch(undefined);
|
||||||
|
try {
|
||||||
|
if (auswahl.length > 0 && !zielkontenFertig) {
|
||||||
|
setLaeuft("Zielkonten werden entfernt …");
|
||||||
|
const antwort = await widerrufZielkontenAktion(schluesselIds, auswahl);
|
||||||
|
if (antwort.bestaetigen) setBestaetigen(true);
|
||||||
|
if (antwort.fehler || !antwort.vorgaenge) {
|
||||||
|
setFehler(antwort.fehler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fehlschlaege: string[] = [];
|
||||||
|
for (const vorgang of antwort.vorgaenge) {
|
||||||
|
const ende = await warten(vorgang.id);
|
||||||
|
if (ende.status !== "ERLEDIGT")
|
||||||
|
fehlschlaege.push(`${vorgang.beschriftung}: ${ende.fehler ?? "fehlgeschlagen"}`);
|
||||||
|
}
|
||||||
|
if (fehlschlaege.length > 0) {
|
||||||
|
setFehler(`Zielkonto nicht entfernt – ${fehlschlaege.join(" · ")}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setZielkontenFertig(true);
|
||||||
|
}
|
||||||
|
setLaeuft("Widerruf wird abgeschlossen …");
|
||||||
|
const antwort = await widerrufAbschliessenAktion(schluesselIds);
|
||||||
|
if (antwort.fehler) {
|
||||||
|
setFehler(antwort.fehler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const offeneSchluessel = (antwort.ergebnisse ?? []).filter((e) => e.status === "OFFEN");
|
||||||
|
if (offeneSchluessel.length > 0) {
|
||||||
|
setNoch(offeneSchluessel.flatMap((e) => e.offen));
|
||||||
|
} else {
|
||||||
|
nachAbschluss?.();
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (fehler) {
|
||||||
|
setFehler((fehler as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLaeuft(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestaetigenZiel = (() => {
|
||||||
|
const url = new URL(ruecksprung, "http://x");
|
||||||
|
if (auswahl.length > 0) url.searchParams.set("zielkonten", auswahl.join(","));
|
||||||
|
else url.searchParams.delete("zielkonten");
|
||||||
|
return `${url.pathname}${url.search}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Schrittleiste
|
||||||
|
schritte={ohneSchritt ? SCHRITTE.filter((s) => s !== "Zielkonten") : SCHRITTE}
|
||||||
|
aktuell={ohneSchritt && aktuell >= 2 ? aktuell - 1 : aktuell}
|
||||||
|
beschriftung="Schritte des Widerrufs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{offen.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-[14px] font-medium">Noch nicht entfernt ({offen.length}):</p>
|
||||||
|
<ul className="flex flex-col gap-1 font-mono text-[13px]">
|
||||||
|
{offen.map((o) => (
|
||||||
|
<li key={o.id} className="break-all">
|
||||||
|
<span className={o.zustand === "FEHLGESCHLAGEN" ? "text-danger" : "text-accent"}>●</span>{" "}
|
||||||
|
{o.ziel}
|
||||||
|
<span className="text-muted font-sans">
|
||||||
|
{" "}
|
||||||
|
· {ZUSTAND_TEXT[o.zustand] ?? o.zustand.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{privatOhneRecht ? (
|
||||||
|
<p className="text-danger text-[14px]">Private Keys entfernt nur ein Admin.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-muted text-[13.5px]">
|
||||||
|
Der Dry-Run zeigt je Host, was entfernt wird – und warum ein Host gerade nicht geht (etwa
|
||||||
|
nicht erreichbar). Fehlgeschlagene Ziele bleiben offen; ein neuer Dry-Run versucht es erneut.
|
||||||
|
</p>
|
||||||
|
<AusrollenPanel
|
||||||
|
auswahl={schluesselIds.length === 1 ? { schluesselId: schluesselIds[0]! } : { schluesselIds }}
|
||||||
|
offen={offen.length}
|
||||||
|
nachAusrollen={() => void abschliessen()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{waehlbar.length > 0 && (
|
||||||
|
<fieldset className="flex flex-col gap-1">
|
||||||
|
<legend className="mb-1 text-[14px] font-medium">Zielkonten entfernen (optional)</legend>
|
||||||
|
{zielkonten.map((k) => {
|
||||||
|
const genutzt = k.nochGenutztVon.length > 0;
|
||||||
|
return (
|
||||||
|
<Ankreuzfeld
|
||||||
|
key={k.id}
|
||||||
|
disabled={genutzt || zielkontenFertig || Boolean(laeuft)}
|
||||||
|
checked={auswahl.includes(k.id)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAuswahl((alt) => (e.target.checked ? [...alt, k.id] : alt.filter((id) => id !== k.id)))
|
||||||
|
}
|
||||||
|
beschriftung={
|
||||||
|
<>
|
||||||
|
Benutzer <span className="font-mono">{k.benutzername}</span> auf{" "}
|
||||||
|
<span className="font-mono">{k.hostName}</span> entfernen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
erklaerung={
|
||||||
|
genutzt
|
||||||
|
? `Bleibt: wird noch von „${k.nochGenutztVon.join("“, „")}“ genutzt.`
|
||||||
|
: "Nach dem Entfernen der Schlüssel; verlangt eine frische Bestätigung."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{noch && noch.length > 0 && (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className="border-danger-border bg-danger-soft text-danger rounded-box border px-4 py-3 text-[14px]"
|
||||||
|
>
|
||||||
|
Noch nicht widerrufen – offen: {noch.map((o) => o.ziel).join(", ")}. Bitte einen neuen Dry-Run
|
||||||
|
starten.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Formularfehler text={fehler} />
|
||||||
|
{bestaetigen && (
|
||||||
|
<a
|
||||||
|
href={`/bestaetigen?ziel=${encodeURIComponent(bestaetigenZiel)}`}
|
||||||
|
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" aria-hidden /> Anmeldung bestätigen, danach hier fortsetzen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{laeuft && (
|
||||||
|
<p role="status" className="text-muted text-[14px]">
|
||||||
|
{laeuft}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{offen.length === 0 && (
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<CheckCheck className="size-4" />}
|
||||||
|
disabled={Boolean(laeuft)}
|
||||||
|
onClick={() => void abschliessen()}
|
||||||
|
>
|
||||||
|
Widerruf abschließen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Ban } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
|
||||||
|
import { widerrufEinleitenAktion } from "@/server/actions/widerruf";
|
||||||
|
import type { WiderrufUebersicht } from "@/server/queries/widerruf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bestaetigung eines Widerrufs (#024): listet genau auf, was auf welchem Host passiert, und
|
||||||
|
* laesst Zielkonten (Standard: aus) zum Entfernen auswaehlen. Der Klick merkt nur vor – auf den
|
||||||
|
* Hosts aendert sich erst nach Dry-Run und „Anwenden“ etwas.
|
||||||
|
*
|
||||||
|
* Die Auswahl der Zielkonten reist als `?zielkonten=` in der Adresse weiter: so uebersteht sie
|
||||||
|
* ein Neuladen und die erneute Bestaetigung der Anmeldung.
|
||||||
|
*/
|
||||||
|
export function WiderrufFormular({
|
||||||
|
uebersicht,
|
||||||
|
ruecksprung,
|
||||||
|
abbrechen,
|
||||||
|
}: {
|
||||||
|
uebersicht: WiderrufUebersicht;
|
||||||
|
/** Wohin nach dem Vormerken (Schluesselseite bzw. Massenwiderruf-Seite). */
|
||||||
|
ruecksprung: string;
|
||||||
|
abbrechen?: () => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [auswahl, setAuswahl] = useState<string[]>([]);
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [bestaetigen, setBestaetigen] = useState(false);
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt);
|
||||||
|
const mehrere = uebersicht.schluessel.length > 1;
|
||||||
|
const knopfText = mehrere ? `${uebersicht.schluessel.length} Schlüssel widerrufen` : "Widerruf vorbereiten";
|
||||||
|
|
||||||
|
const ziel = (() => {
|
||||||
|
const url = new URL(ruecksprung, "http://x");
|
||||||
|
if (auswahl.length > 0) url.searchParams.set("zielkonten", auswahl.join(","));
|
||||||
|
return `${url.pathname}${url.search}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<WiderrufInhalt uebersicht={uebersicht} mehrere={mehrere} />
|
||||||
|
|
||||||
|
{uebersicht.zielkonten.length > 0 && (
|
||||||
|
<fieldset className="flex flex-col gap-1">
|
||||||
|
<legend className="mb-1 text-[14px] font-medium">Zielkonten zusätzlich entfernen (optional)</legend>
|
||||||
|
{uebersicht.zielkonten.map((k) => {
|
||||||
|
const genutzt = k.nochGenutztVon.length > 0;
|
||||||
|
return (
|
||||||
|
<Ankreuzfeld
|
||||||
|
key={k.id}
|
||||||
|
name="zielkonten"
|
||||||
|
value={k.id}
|
||||||
|
disabled={genutzt}
|
||||||
|
checked={auswahl.includes(k.id)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAuswahl((alt) => (e.target.checked ? [...alt, k.id] : alt.filter((id) => id !== k.id)))
|
||||||
|
}
|
||||||
|
beschriftung={
|
||||||
|
<>
|
||||||
|
Benutzer <span className="font-mono">{k.benutzername}</span> auf{" "}
|
||||||
|
<span className="font-mono">{k.hostName}</span> entfernen
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
erklaerung={
|
||||||
|
genutzt
|
||||||
|
? `Bleibt: wird noch von „${k.nochGenutztVon.join("“, „")}“ genutzt.`
|
||||||
|
: `Von sshkv angelegt. Benutzer samt Home${k.sudoModus !== "KEINER" ? " und sudoers-Datei" : ""} werden gelöscht – verlangt eine frische Bestätigung.`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-border bg-bg rounded-box border px-4 py-3 text-[13.5px]">
|
||||||
|
<p className="mb-1.5 font-medium">So geht es weiter</p>
|
||||||
|
<ol className="text-muted list-decimal space-y-1 pl-5">
|
||||||
|
<li>
|
||||||
|
Die Zuweisungen werden zum Entfernen vorgemerkt; der Schlüssel lässt sich nicht mehr zuweisen.
|
||||||
|
</li>
|
||||||
|
<li>Der Dry-Run zeigt jede Änderung. Erst „Anwenden“ ändert etwas auf den Hosts.</li>
|
||||||
|
<li>
|
||||||
|
Erst wenn alle Entfernungen erfolgreich waren, gilt der Schlüssel als widerrufen. Scheitert ein
|
||||||
|
Host, bleibt er im Zwischenzustand und die Seite zeigt, was fehlt.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gesperrt.map((s) => (
|
||||||
|
<p key={s.id} className="text-danger text-[14px]">
|
||||||
|
{s.gesperrt}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<Formularfehler text={fehler} />
|
||||||
|
{bestaetigen && (
|
||||||
|
<a
|
||||||
|
href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`}
|
||||||
|
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium"
|
||||||
|
>
|
||||||
|
{/* Nennt den Knopf, den es hier wirklich gibt – beim Massenwiderruf heisst er anders (#032). */}
|
||||||
|
Anmeldung bestätigen und danach erneut „{knopfText}“ wählen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
{abbrechen && <Button onClick={abbrechen}>Abbrechen</Button>}
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Ban className="size-4" />}
|
||||||
|
disabled={laeuft || gesperrt.length > 0}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
const antwort = await widerrufEinleitenAktion(uebersicht.schluessel.map((s) => s.id));
|
||||||
|
if (antwort.bestaetigen) setBestaetigen(true);
|
||||||
|
if (antwort.fehler) {
|
||||||
|
setFehler(antwort.fehler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
abbrechen?.();
|
||||||
|
router.replace(ziel);
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laeuft ? "Wird vorgemerkt …" : knopfText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Liste „was passiert“ – im Dialog und auf der Massenwiderruf-Seite. */
|
||||||
|
export function WiderrufInhalt({
|
||||||
|
uebersicht,
|
||||||
|
mehrere,
|
||||||
|
}: {
|
||||||
|
uebersicht: WiderrufUebersicht;
|
||||||
|
mehrere: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{uebersicht.schluessel.map((s) => {
|
||||||
|
const oeffentlich = uebersicht.public.filter((v) => v.schluesselId === s.id);
|
||||||
|
const privat = uebersicht.privat.filter((v) => v.schluesselId === s.id);
|
||||||
|
return (
|
||||||
|
<section key={s.id} className="flex flex-col gap-2" aria-label={`Widerruf von ${s.name}`}>
|
||||||
|
{mehrere && (
|
||||||
|
<h3 className="text-[14.5px] font-semibold break-words">
|
||||||
|
{s.name}
|
||||||
|
{s.person && <span className="text-muted font-normal"> · {s.person}</span>}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
{oeffentlich.length === 0 && privat.length === 0 && (
|
||||||
|
<p className="text-muted text-[13.5px]">
|
||||||
|
Keine Zuweisungen – der Schlüssel wird ohne Änderung auf einem Host widerrufen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{oeffentlich.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted mb-1 text-[13px]">Public Key wird aus authorized_keys entfernt:</p>
|
||||||
|
<ul className="flex flex-col gap-1 font-mono text-[13px]">
|
||||||
|
{oeffentlich.map((v) => (
|
||||||
|
<li key={v.id} className="break-all">
|
||||||
|
<span className="text-danger">−</span> {v.ziel}
|
||||||
|
{v.archiviert && (
|
||||||
|
<span className="text-muted font-sans">
|
||||||
|
{" "}
|
||||||
|
· Host archiviert – sshkv ändert dort nichts
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{privat.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-muted mb-1 text-[13px]">Private Key wird vom Quell-Host entfernt:</p>
|
||||||
|
<ul className="flex flex-col gap-1.5 font-mono text-[13px]">
|
||||||
|
{privat.map((v) => (
|
||||||
|
<li key={v.id}>
|
||||||
|
<span className="break-all">
|
||||||
|
<span className="text-danger">−</span> {v.ziel}:{v.pfad}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted block pl-3.5 font-sans text-[12.5px]">
|
||||||
|
{v.beimWiderruf === "UMBENENNEN"
|
||||||
|
? `Wird umbenannt in …${v.pfad.split("/").pop()}.sshkv-widerrufen.<Zeit>`
|
||||||
|
: "Wird gelöscht"}
|
||||||
|
, dazu die .pub-Datei
|
||||||
|
{v.alias ? `, der config-Eintrag „Host ${v.alias}“ und sein known_hosts-Block` : ""}
|
||||||
|
{v.archiviert ? " · Host archiviert – sshkv ändert dort nichts" : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Ban, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import type { WiderrufUebersicht } from "@/server/queries/widerruf";
|
||||||
|
import { WiderrufFormular } from "./WiderrufFormular";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Widerrufen“ auf der Schluesselseite (#024): Gefahr-Knopf mit Bestaetigungsdialog. Offen/zu
|
||||||
|
* als Zustand wie im AusrollDialog – kein Render-Pfad liest die Ref.
|
||||||
|
*/
|
||||||
|
export function WiderrufKnopf({
|
||||||
|
uebersicht,
|
||||||
|
ruecksprung,
|
||||||
|
beschriftung = "Widerrufen",
|
||||||
|
}: {
|
||||||
|
uebersicht: WiderrufUebersicht;
|
||||||
|
ruecksprung: string;
|
||||||
|
beschriftung?: string;
|
||||||
|
}) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const name = uebersicht.schluessel[0]?.name ?? "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (offen && !element.open) element.showModal();
|
||||||
|
if (!offen && element.open) element.close();
|
||||||
|
}, [offen]);
|
||||||
|
|
||||||
|
const schliessen = () => setOffen(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variante="gefahr" icon={<Ban className="size-4" />} onClick={() => setOffen(true)}>
|
||||||
|
{beschriftung}
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-labelledby="widerruf-titel"
|
||||||
|
onClose={schliessen}
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto max-h-[calc(100dvh-32px)] w-[calc(100%-32px)] max-w-2xl overflow-y-auto border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<h2 id="widerruf-titel" className="font-display text-[18px] font-semibold break-words">
|
||||||
|
„{name}“ widerrufen?
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={schliessen}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="text-muted hover:text-text hover:bg-raised rounded-feld -mt-2 -mr-2 flex size-11 shrink-0 items-center justify-center"
|
||||||
|
>
|
||||||
|
<X className="size-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{offen && (
|
||||||
|
<WiderrufFormular uebersicht={uebersicht} ruecksprung={ruecksprung} abbrechen={schliessen} />
|
||||||
|
)}
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||