Compare commits
@@ -15,6 +15,39 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [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):** 3 h 40 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:** 11 h 03 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025.
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -25,14 +25,22 @@
|
|||||||
| #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 |
|
||||||
|
| #030 Gitea-Abgleich (vorbereitet) | 0 h 05 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**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,3 +92,37 @@
|
|||||||
- **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).
|
||||||
|
|||||||
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 144 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: 98 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sshkv",
|
"name": "sshkv",
|
||||||
"version": "0.2.0",
|
"version": "0.4.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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -110,6 +110,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VorgangsStatus {
|
enum VorgangsStatus {
|
||||||
@@ -298,6 +302,9 @@ model Schluessel {
|
|||||||
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)
|
||||||
@@ -329,6 +336,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?
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Ü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,
|
||||||
|
private readonly token: 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: `token ${this.token}`,
|
||||||
|
"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 den Token aus den Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`).
|
||||||
|
* So genügt ein einziger Token für Push und API – er muss nicht zusätzlich in der .env stehen.
|
||||||
|
*/
|
||||||
|
function tokenAusGitCredentials(basis: string): 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);
|
||||||
|
if (passwort?.[1]) return 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 token = process.env.GITEA_TOKEN || (basis ? (tokenAusGitCredentials(basis) ?? "") : "");
|
||||||
|
const tokenQuelle = process.env.GITEA_TOKEN ? "Umgebungsvariable" : "Git-Zugangsdaten";
|
||||||
|
|
||||||
|
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, token);
|
||||||
|
|
||||||
|
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,16 @@ 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"]',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
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 {
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
letzterAbgleich,
|
letzterAbgleich,
|
||||||
type AktionsArt,
|
type AktionsArt,
|
||||||
} from "@/beispiel/uebersicht";
|
} from "@/beispiel/uebersicht";
|
||||||
|
import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
|
||||||
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";
|
||||||
@@ -52,8 +53,8 @@ export default async function Uebersicht() {
|
|||||||
}
|
}
|
||||||
aktionen={
|
aktionen={
|
||||||
<>
|
<>
|
||||||
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
<AlleAbgleichenKnopf />
|
||||||
<Button href="/zuweisungen" icon={<Route className="size-4" />}>
|
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||||
Host-zu-Host-Assistent
|
Host-zu-Host-Assistent
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -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,15 @@ 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 { 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 +192,61 @@ 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");
|
||||||
|
const widerrufbar =
|
||||||
|
!imWiderruf &&
|
||||||
|
!(s.nachfolger?.status === "AKTIV") &&
|
||||||
|
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 darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
|
||||||
|
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf));
|
||||||
|
// 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 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 +254,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 +275,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 +288,99 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
aktionen={
|
aktionen={
|
||||||
verwalten && (
|
(verwalten || rotierbar || (widerrufbar && widerruf)) && (
|
||||||
|
<>
|
||||||
|
{verwalten && (
|
||||||
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</Button>
|
</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 && (
|
||||||
|
<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>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
</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.
|
||||||
|
{s.nachfolger && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
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,6 +454,7 @@ 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
|
||||||
titel="Dry-Run-Vorschau"
|
titel="Dry-Run-Vorschau"
|
||||||
@@ -336,6 +470,42 @@ 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>
|
||||||
|
)}
|
||||||
|
{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,82 @@
|
|||||||
|
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 && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
|
||||||
|
? await offeneEntfernungen([alt.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,
|
||||||
|
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,118 @@
|
|||||||
|
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 { 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[] }>;
|
||||||
|
|
||||||
|
function liste(wert: string | string[] | undefined): string[] {
|
||||||
|
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
|
||||||
|
return [...new Set(roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9]{1,64}$/i.test(w)))].slice(
|
||||||
|
0,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = liste(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 = liste(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">
|
||||||
|
<dt className="text-muted text-[12.5px] font-medium">{titel}</dt>
|
||||||
|
<dd className="min-w-0 text-[14px]">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
titel="Vorlagen"
|
||||||
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup"
|
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup – sie füllen den Assistenten vor."
|
||||||
phase={3}
|
aktionen={anlegen}
|
||||||
issue="#019"
|
|
||||||
/>
|
/>
|
||||||
|
{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">
|
||||||
|
<Seitenkopf
|
||||||
titel="Zuweisungen"
|
titel="Zuweisungen"
|
||||||
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt"
|
untertitel="Welcher Schlüssel auf welchem Host verwaltet ist"
|
||||||
phase={2}
|
aktionen={
|
||||||
issue="#016"
|
<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,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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,241 @@
|
|||||||
|
"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 {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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,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,209 @@
|
|||||||
|
"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 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"
|
||||||
|
>
|
||||||
|
Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ 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 …"
|
||||||
|
: mehrere
|
||||||
|
? `${uebersicht.schluessel.length} Schlüssel widerrufen`
|
||||||
|
: "Widerruf vorbereiten"}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowRight, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||||
|
import { PublicZuweisenFormular } from "@/components/ausrollen/PublicZuweisenFormular";
|
||||||
|
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { InnereBox } from "@/components/ui/Karte";
|
||||||
|
import { KENNUNG_TEXT, type MatrixHost, type MatrixSchluessel, type Zelle } from "@/lib/zuweisungsmatrix";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
|
||||||
|
export type ZellenAuswahl = { readonly schluesselId: string; readonly hostId: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail einer Matrixzelle (#025): was dort verwaltet ist, Entfernen und Zuweisen mit den
|
||||||
|
* vorhandenen Aktionen. Ausgerollt wird weiter nur ueber den Dry-Run der Schluesselseite –
|
||||||
|
* die Matrix plant, sie schreibt nichts auf Hosts.
|
||||||
|
*
|
||||||
|
* Die Zelle kommt aus den Props: nach `router.refresh()` zeigt der offene Dialog sofort den
|
||||||
|
* neuen Stand, ohne eigenen Zustand nachzufuehren.
|
||||||
|
*/
|
||||||
|
export function ZellenDialog({
|
||||||
|
schluessel,
|
||||||
|
host,
|
||||||
|
zelle,
|
||||||
|
darfZuweisen,
|
||||||
|
darfPrivatEntfernen,
|
||||||
|
auswahl,
|
||||||
|
schliessen,
|
||||||
|
}: {
|
||||||
|
schluessel: MatrixSchluessel;
|
||||||
|
host: MatrixHost | undefined;
|
||||||
|
zelle: Zelle | undefined;
|
||||||
|
darfZuweisen: boolean;
|
||||||
|
darfPrivatEntfernen: boolean;
|
||||||
|
auswahl?: ZielAuswahl;
|
||||||
|
schliessen: () => void;
|
||||||
|
}) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
// Nach erfolgreichem Zuweisen ein frisches Formular – sonst schickt ein zweiter Klick
|
||||||
|
// dieselbe Zuweisung noch einmal und erntet „bereits zugewiesen“.
|
||||||
|
const [formularNr, setFormularNr] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current;
|
||||||
|
if (element && !element.open) element.showModal();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const titel = host ? `${schluessel.name} auf ${host.name}` : schluessel.name;
|
||||||
|
const vorauswahl = host && auswahl?.hosts.some((h) => h.id === host.id) ? `host:${host.id}` : undefined;
|
||||||
|
const eintraege = zelle?.eintraege ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-label={titel}
|
||||||
|
onClose={schliessen}
|
||||||
|
className="border-border-strong bg-surface text-text rounded-karte m-auto max-h-[calc(100dvh-32px)] w-[calc(100%-32px)] overflow-y-auto overscroll-contain max-w-xl border p-5 backdrop:bg-black/70 sm:p-6"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="font-display text-[18px] font-semibold break-words">{schluessel.name}</h2>
|
||||||
|
<p className="text-muted mt-0.5 text-[13.5px]">
|
||||||
|
{host ? (
|
||||||
|
<>
|
||||||
|
auf <span className="font-mono">{host.name}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Einem Host oder einer Hostgruppe zuweisen"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[13.5px]">
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${schluessel.id}`}
|
||||||
|
className="text-accent inline-flex min-h-11 items-center hover:underline"
|
||||||
|
>
|
||||||
|
Zum Schlüssel
|
||||||
|
</Link>
|
||||||
|
{host && (
|
||||||
|
<Link
|
||||||
|
href={`/hosts/${host.id}`}
|
||||||
|
className="text-accent inline-flex min-h-11 items-center hover:underline"
|
||||||
|
>
|
||||||
|
Zum Host
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{host && (
|
||||||
|
<section className="mt-3 flex flex-col gap-3" aria-label="Verwendungen in dieser Zelle">
|
||||||
|
{eintraege.length === 0 ? (
|
||||||
|
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-4 text-center text-[14px]">
|
||||||
|
Auf diesem Host ist der Schlüssel nicht zugewiesen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
eintraege.map((e) => (
|
||||||
|
<InnereBox
|
||||||
|
key={`${e.art}-${e.verwendungId}`}
|
||||||
|
className="flex items-start justify-between gap-3"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<p className="text-[14px] font-medium">
|
||||||
|
{KENNUNG_TEXT[e.kennung]}
|
||||||
|
{e.gruppe && <> „{e.gruppe.name}“</>}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted text-[13px]">
|
||||||
|
Zielbenutzer <span className="text-text font-mono">{e.zielbenutzer}</span>
|
||||||
|
</p>
|
||||||
|
{e.art === "public" ? (
|
||||||
|
<p className="text-muted text-[13px] break-all">
|
||||||
|
Optionen <span className="text-text font-mono text-[12.5px]">{e.optionen ?? "–"}</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted text-[13px] break-all">
|
||||||
|
Pfad <span className="text-text font-mono text-[12.5px]">{e.pfad}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<VerwendungsZustand zustand={e.zustand} />
|
||||||
|
{e.gruppe && darfZuweisen && (
|
||||||
|
<p className="text-muted text-[12.5px]">
|
||||||
|
Entfernen gilt für alle Hosts der Gruppe „{e.gruppe.name}“.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<EintragAktionen
|
||||||
|
art={e.art}
|
||||||
|
verwendungId={e.verwendungId}
|
||||||
|
schluesselId={schluessel.id}
|
||||||
|
darfEntfernen={
|
||||||
|
e.zustand !== "ENTFERNEN" && (e.art === "public" ? darfZuweisen : darfPrivatEntfernen)
|
||||||
|
}
|
||||||
|
testbar={false}
|
||||||
|
/>
|
||||||
|
</InnereBox>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darfZuweisen && auswahl && (
|
||||||
|
<section className="border-border mt-5 flex flex-col gap-3 border-t pt-5">
|
||||||
|
<h3 className="text-[15px] font-semibold">Public Key zuweisen</h3>
|
||||||
|
<PublicZuweisenFormular
|
||||||
|
key={formularNr}
|
||||||
|
schluesselId={schluessel.id}
|
||||||
|
auswahl={auswahl}
|
||||||
|
{...(vorauswahl ? { vorauswahl } : {})}
|
||||||
|
beiErfolg={() => {
|
||||||
|
setFormularNr((n) => n + 1);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darfZuweisen && (
|
||||||
|
<div className="border-border mt-5 flex flex-col gap-2 border-t pt-4">
|
||||||
|
<p className="text-muted text-[13px]">
|
||||||
|
Zuweisen und Entfernen ändern auf dem Host noch nichts – erst der Dry-Run auf der Schlüsselseite
|
||||||
|
zeigt und schreibt die Änderung.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
href={`/schluessel/${schluessel.id}`}
|
||||||
|
klein
|
||||||
|
className="self-start"
|
||||||
|
icon={<ArrowRight className="size-4" />}
|
||||||
|
>
|
||||||
|
Zum Dry-Run
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ZUSTAND_ANZEIGE } from "@/components/ausrollen/VerwendungsZustand";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import {
|
||||||
|
KENNUNG_TEXT,
|
||||||
|
ZUSTAND_KURZ,
|
||||||
|
ZUSTAND_TEXT,
|
||||||
|
zellenSchluessel,
|
||||||
|
type MatrixHost,
|
||||||
|
type MatrixSchluessel,
|
||||||
|
type Zelle,
|
||||||
|
} from "@/lib/zuweisungsmatrix";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
import { ZellenDialog, type ZellenAuswahl } from "./ZellenDialog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matrix Schluessel × Host (#025). Ab md eine Tabelle mit eigenem Scrollbereich – die Seite
|
||||||
|
* selbst scrollt nie seitwaerts –, darunter eine Liste je Schluessel, weil auf 390 px keine
|
||||||
|
* zweite Spalte neben den Schluesselnamen passt.
|
||||||
|
*/
|
||||||
|
export type MatrixProps = {
|
||||||
|
zeilen: readonly MatrixSchluessel[];
|
||||||
|
spalten: readonly MatrixHost[];
|
||||||
|
/** Nur belegte Zellen, Schluessel `${schluesselId}:${hostId}`. */
|
||||||
|
zellen: Readonly<Record<string, Zelle>>;
|
||||||
|
darfZuweisen: boolean;
|
||||||
|
darfPrivatEntfernen: boolean;
|
||||||
|
auswahl?: ZielAuswahl;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Langtext fuer title und Screenreader: Art, Gruppe, Benutzer und Zustand je Eintrag. */
|
||||||
|
function zellenText(zelle: Zelle): string {
|
||||||
|
return zelle.eintraege
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
`${KENNUNG_TEXT[e.kennung]}${e.gruppe ? ` ${e.gruppe.name}` : ""}, Benutzer ${e.zielbenutzer}, ${ZUSTAND_TEXT[e.zustand]}`,
|
||||||
|
)
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function zielbenutzerKurz(zelle: Zelle): string {
|
||||||
|
const [erster, ...rest] = zelle.zielbenutzer;
|
||||||
|
return rest.length > 0 ? `${erster} +${rest.length}` : (erster ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Zuweisungsmatrix({
|
||||||
|
zeilen,
|
||||||
|
spalten,
|
||||||
|
zellen,
|
||||||
|
darfZuweisen,
|
||||||
|
darfPrivatEntfernen,
|
||||||
|
auswahl,
|
||||||
|
}: MatrixProps) {
|
||||||
|
const [offen, setOffen] = useState<ZellenAuswahl>();
|
||||||
|
const zuweisbar = (s: MatrixSchluessel) => darfZuweisen && !!auswahl && s.kategorie !== "DEPLOYMENT";
|
||||||
|
|
||||||
|
const schluessel = offen ? zeilen.find((s) => s.id === offen.schluesselId) : undefined;
|
||||||
|
const host = offen?.hostId ? spalten.find((h) => h.id === offen.hostId) : undefined;
|
||||||
|
const zelle = offen?.hostId ? zellen[zellenSchluessel(offen.schluesselId, offen.hostId)] : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop: Kopfzeile und erste Spalte bleiben beim Scrollen stehen. Der Scrollbereich
|
||||||
|
braucht eine eigene Hoehe, sonst klebt die Kopfzeile am Container statt am Fenster. */}
|
||||||
|
<div className="border-border bg-surface relative hidden max-h-[calc(100dvh-11rem)] overflow-auto rounded-[12px] border md:block">
|
||||||
|
<table className="min-w-full border-separate border-spacing-0 text-left text-[13px]">
|
||||||
|
<caption className="sr-only">
|
||||||
|
Zuweisungen: Schlüssel in den Zeilen, Hosts in den Spalten. Eine Zelle öffnet die Details.
|
||||||
|
</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="bg-surface border-border-strong text-muted sticky top-0 left-0 z-30 min-w-56 border-r border-b px-3 py-2.5 text-[13px] font-medium"
|
||||||
|
>
|
||||||
|
Schlüssel
|
||||||
|
</th>
|
||||||
|
{spalten.map((h) => (
|
||||||
|
<th
|
||||||
|
key={h.id}
|
||||||
|
scope="col"
|
||||||
|
className="bg-surface border-border-strong sticky top-0 z-20 border-b px-1 py-2.5 align-bottom font-medium"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/hosts/${h.id}`}
|
||||||
|
title={h.name}
|
||||||
|
className="text-muted hover:text-accent block max-w-28 truncate px-1.5 font-mono text-[12.5px] transition-colors"
|
||||||
|
>
|
||||||
|
{h.name}
|
||||||
|
</Link>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{zeilen.map((s) => (
|
||||||
|
<tr key={s.id} className="group">
|
||||||
|
<th
|
||||||
|
scope="row"
|
||||||
|
className="bg-surface border-border group-hover:bg-raised sticky left-0 z-10 max-w-72 border-r border-b px-3 py-1.5 font-normal transition-colors"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
title={s.name}
|
||||||
|
className="text-text hover:text-accent block truncate text-[13.5px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
{s.person && <span className="text-muted block truncate text-[12px]">{s.person.name}</span>}
|
||||||
|
</th>
|
||||||
|
{spalten.map((h) => {
|
||||||
|
const z = zellen[zellenSchluessel(s.id, h.id)];
|
||||||
|
return (
|
||||||
|
<td key={h.id} className="border-border border-b p-0.5 align-middle">
|
||||||
|
{z ? (
|
||||||
|
<BelegteZelle
|
||||||
|
zelle={z}
|
||||||
|
schluessel={s}
|
||||||
|
host={h}
|
||||||
|
oeffnen={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||||
|
/>
|
||||||
|
) : zuweisbar(s) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||||
|
title={`${s.name} auf ${h.name} zuweisen`}
|
||||||
|
className="text-border-strong hover:text-accent hover:bg-raised rounded-feld flex min-h-11 w-full min-w-20 items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="sr-only">
|
||||||
|
{s.name} auf {h.name}: nicht zugewiesen – Details und Zuweisen
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-border-strong flex min-h-11 min-w-20 items-center justify-center">
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="sr-only">nicht zugewiesen</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telefon: je Schluessel nur die Hosts, auf denen er verwaltet ist. */}
|
||||||
|
<ul className="flex flex-col gap-3 md:hidden">
|
||||||
|
{zeilen.map((s) => {
|
||||||
|
const belegt = spalten.flatMap((h) => {
|
||||||
|
const z = zellen[zellenSchluessel(s.id, h.id)];
|
||||||
|
return z ? [{ host: h, zelle: z }] : [];
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<li key={s.id} className="border-border bg-surface rounded-karte min-w-0 border p-4">
|
||||||
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
className="text-text hover:text-accent block truncate text-[14.5px] font-medium"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted block text-[12.5px]">
|
||||||
|
{belegt.length === 1 ? "1 Host" : `${belegt.length} Hosts`}
|
||||||
|
{s.person && <> · {s.person.name}</>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{zuweisbar(s) && (
|
||||||
|
<Button
|
||||||
|
klein
|
||||||
|
icon={<Plus className="size-4" />}
|
||||||
|
onClick={() => setOffen({ schluesselId: s.id, hostId: null })}
|
||||||
|
>
|
||||||
|
Zuweisen
|
||||||
|
<span className="sr-only"> – {s.name}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{belegt.length > 0 && (
|
||||||
|
<ul className="mt-3 flex flex-col">
|
||||||
|
{belegt.map(({ host: h, zelle: z }) => {
|
||||||
|
const anzeige = ZUSTAND_ANZEIGE[z.zustand];
|
||||||
|
return (
|
||||||
|
<li key={h.id} className="border-border border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||||
|
className="hover:bg-raised/60 rounded-box -mx-2 flex min-h-11 w-[calc(100%+1rem)] min-w-0 items-center justify-between gap-3 px-2 py-2 text-left"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="text-text block truncate font-mono text-[13px]">{h.name}</span>
|
||||||
|
<span className="text-muted block truncate text-[12.5px]">
|
||||||
|
{z.kennungen.join(" ")} ·{" "}
|
||||||
|
<span className="font-mono">{zielbenutzerKurz(z)}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"inline-flex shrink-0 items-center gap-1.5 text-[13px] whitespace-nowrap",
|
||||||
|
anzeige.klasse,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{anzeige.punkt}</span>
|
||||||
|
{ZUSTAND_TEXT[z.zustand]}
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">: {zellenText(z)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{offen && schluessel && (
|
||||||
|
<ZellenDialog
|
||||||
|
schluessel={schluessel}
|
||||||
|
host={host}
|
||||||
|
zelle={zelle}
|
||||||
|
darfZuweisen={zuweisbar(schluessel)}
|
||||||
|
darfPrivatEntfernen={darfPrivatEntfernen}
|
||||||
|
{...(auswahl ? { auswahl } : {})}
|
||||||
|
schliessen={() => setOffen(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BelegteZelle({
|
||||||
|
zelle,
|
||||||
|
schluessel,
|
||||||
|
host,
|
||||||
|
oeffnen,
|
||||||
|
}: {
|
||||||
|
zelle: Zelle;
|
||||||
|
schluessel: MatrixSchluessel;
|
||||||
|
host: MatrixHost;
|
||||||
|
oeffnen: () => void;
|
||||||
|
}) {
|
||||||
|
const anzeige = ZUSTAND_ANZEIGE[zelle.zustand];
|
||||||
|
const text = zellenText(zelle);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={oeffnen}
|
||||||
|
title={text}
|
||||||
|
className="hover:bg-raised rounded-feld flex min-h-11 w-full min-w-20 flex-col items-start justify-center gap-0.5 px-2 py-1 text-left transition-colors"
|
||||||
|
>
|
||||||
|
<span aria-hidden className="flex items-center gap-1.5 whitespace-nowrap">
|
||||||
|
<span className={anzeige.klasse}>{anzeige.punkt}</span>
|
||||||
|
<span className="text-text font-medium">{zelle.kennungen.join(" ")}</span>
|
||||||
|
<span className={clsx("text-[11.5px]", anzeige.klasse)}>{ZUSTAND_KURZ[zelle.zustand]}</span>
|
||||||
|
</span>
|
||||||
|
<span aria-hidden className="text-muted block max-w-24 truncate font-mono text-[11.5px]">
|
||||||
|
{zielbenutzerKurz(zelle)}
|
||||||
|
</span>
|
||||||
|
<span className="sr-only">
|
||||||
|
{schluessel.name} auf {host.name}: {text}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* CSV fuer Excel (#025). Framework-frei, damit spaetere Exporte (Protokoll, Phase 5) dieselben
|
||||||
|
* Regeln nutzen.
|
||||||
|
*
|
||||||
|
* - Semikolon als Trenner und UTF-8-BOM: ein deutsches Excel oeffnet die Datei sonst als eine
|
||||||
|
* Spalte mit kaputten Umlauten.
|
||||||
|
* - Jeder Wert in Anfuehrungszeichen, damit Kommas in `authorized_keys`-Optionen und
|
||||||
|
* Zeilenumbrueche nichts verschieben.
|
||||||
|
* - Formel-Injektion: Excel und LibreOffice werten Zellen, die mit `= + - @` (oder Tab/CR)
|
||||||
|
* beginnen, als Formel aus – ein Schluesselname wie `=HYPERLINK(…)` wuerde beim Oeffnen
|
||||||
|
* ausgefuehrt. Ein vorangestelltes `'` macht daraus Text (OWASP „CSV Injection“).
|
||||||
|
*/
|
||||||
|
export const CSV_BOM = "";
|
||||||
|
export const CSV_TRENNER = ";";
|
||||||
|
|
||||||
|
const FORMELBEGINN = /^[=+\-@\t\r]/;
|
||||||
|
|
||||||
|
export function csvEntschaerfen(wert: string): string {
|
||||||
|
return FORMELBEGINN.test(wert) ? `'${wert}` : wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csvWert(wert: string | number | null | undefined): string {
|
||||||
|
const text = csvEntschaerfen(wert == null ? "" : String(wert));
|
||||||
|
return `"${text.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csvZeile(werte: readonly (string | number | null | undefined)[]): string {
|
||||||
|
return werte.map(csvWert).join(CSV_TRENNER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ganze Datei: BOM, Kopfzeile, Datenzeilen, CRLF wie Excel selbst. */
|
||||||
|
export function csvDatei(
|
||||||
|
kopf: readonly string[],
|
||||||
|
zeilen: readonly (readonly (string | number | null | undefined)[])[],
|
||||||
|
): string {
|
||||||
|
return CSV_BOM + [kopf, ...zeilen].map(csvZeile).join("\r\n") + "\r\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Namens- und Pfadregeln der gefuehrten Rotation (#023). Rein, damit testbar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NAME_SUFFIX = /\s*\(rotiert \d{2}\.\d{2}\.\d{4}\)$/;
|
||||||
|
const PFAD_SUFFIX = /-r\d{8}(?:-\d+)?$/;
|
||||||
|
|
||||||
|
/** TT.MM.JJJJ und JJJJMMTT nach dem Kalender in Berlin. */
|
||||||
|
export function rotationsDatum(jetzt: Date = new Date()): { anzeige: string; kompakt: string } {
|
||||||
|
const [jahr, monat, tag] = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" })
|
||||||
|
.format(jetzt)
|
||||||
|
.split("-");
|
||||||
|
return { anzeige: `${tag}.${monat}.${jahr}`, kompakt: `${jahr}${monat}${tag}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Backup A → B (rotiert 12.10.2026)“ – ein frueheres Rotations-Suffix wird ersetzt, damit
|
||||||
|
* die Namen bei jeder Rotation nicht laenger werden.
|
||||||
|
*/
|
||||||
|
export function rotationsName(name: string, datum: string, maxLaenge = 200): string {
|
||||||
|
const suffix = ` (rotiert ${datum})`;
|
||||||
|
const basis = name.replace(NAME_SUFFIX, "").trim();
|
||||||
|
return `${basis.slice(0, Math.max(1, maxLaenge - suffix.length))}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuer Pfad fuer den Private Key des Nachfolgers: gleicher Name mit `-rJJJJMMTT`. Ein alter
|
||||||
|
* Suffix wird ersetzt; ist der Pfad (auch durch eine fruehere, entfernte Zuweisung) belegt,
|
||||||
|
* kommt eine laufende Nummer dazu. Zwei Dateien nebeneinander sind gewollt: bis der Nachfolger
|
||||||
|
* getestet ist, bleibt der alte Schluessel benutzbar.
|
||||||
|
*/
|
||||||
|
export function rotationsPfad(pfad: string, datumKompakt: string, belegt: ReadonlySet<string>): string {
|
||||||
|
const basis = pfad.replace(PFAD_SUFFIX, "");
|
||||||
|
let kandidat = `${basis}-r${datumKompakt}`;
|
||||||
|
for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`;
|
||||||
|
return kandidat;
|
||||||
|
}
|
||||||
@@ -200,6 +200,8 @@ export type SchluesselVorgangEingabe = {
|
|||||||
readonly typ?: "ED25519" | "RSA4096";
|
readonly typ?: "ED25519" | "RSA4096";
|
||||||
/** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */
|
/** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */
|
||||||
readonly mitPassphrase?: boolean;
|
readonly mitPassphrase?: boolean;
|
||||||
|
/** Rotation (#023): der abgeloeste Schluessel – so ist die Kette sofort da, auch ohne Browser. */
|
||||||
|
readonly vorgaengerId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */
|
/** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */
|
||||||
@@ -217,6 +219,7 @@ export const vorgangEingabeSchema = z.object({
|
|||||||
.nullable(),
|
.nullable(),
|
||||||
typ: z.enum(["ED25519", "RSA4096"]).optional(),
|
typ: z.enum(["ED25519", "RSA4096"]).optional(),
|
||||||
mitPassphrase: z.boolean().optional(),
|
mitPassphrase: z.boolean().optional(),
|
||||||
|
vorgaengerId: z.string().max(64).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */
|
/** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import {
|
||||||
|
authorizedKeysLesen,
|
||||||
|
blockSetzen,
|
||||||
|
type SchluesselZeile,
|
||||||
|
type SollEintrag,
|
||||||
|
} from "@/lib/ssh/authorized-keys";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abgleich einer authorized_keys mit dem Soll (#021) – rein, damit jede Befundregel einzeln
|
||||||
|
* testbar ist. Der Worker liefert Inhalt und Rechte, die Datenbank Soll und bekannte Keys.
|
||||||
|
*
|
||||||
|
* Befundarten:
|
||||||
|
* - FEHLENDER_SCHLUESSEL: ein zugewiesener Key fehlt im verwalteten Block.
|
||||||
|
* - ZUSAETZLICH_IM_BLOCK: im Block steht etwas, das nicht (mehr) zugewiesen ist – oder mit
|
||||||
|
* anderen Optionen. Jemand hat den Block von Hand bearbeitet.
|
||||||
|
* - UNBEKANNTER_SCHLUESSEL: ausserhalb des Blocks steht ein Key, den sshkv gar nicht kennt.
|
||||||
|
* Bekannte Keys ausserhalb des Blocks sind kein Befund (sie wurden bewusst dort gelassen).
|
||||||
|
* - RECHTE: ~/.ssh oder die Datei sind fuer Gruppe/Welt beschreibbar (sshd ignoriert sie dann
|
||||||
|
* bei StrictModes) oder gehoeren nicht dem Benutzer.
|
||||||
|
* - DATEI_GEAENDERT: der verwaltete Block ist beschaedigt (Anfang ohne Ende o. Ae.).
|
||||||
|
*/
|
||||||
|
export type Befund = {
|
||||||
|
readonly art:
|
||||||
|
"FEHLENDER_SCHLUESSEL" | "ZUSAETZLICH_IM_BLOCK" | "UNBEKANNTER_SCHLUESSEL" | "RECHTE" | "DATEI_GEAENDERT";
|
||||||
|
readonly fingerprint?: string;
|
||||||
|
readonly text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Was der Hostdetail-Seite je Zeile gezeigt wird – nur oeffentliche Angaben. */
|
||||||
|
export type IstEintrag = {
|
||||||
|
readonly fingerprint: string;
|
||||||
|
readonly typ: string;
|
||||||
|
readonly kommentar: string;
|
||||||
|
readonly optionen: string | null;
|
||||||
|
readonly imBlock: boolean;
|
||||||
|
/** verwaltet = im Soll; bekannt = in sshkv, aber hier nicht zugewiesen; fremd = unbekannt. */
|
||||||
|
readonly herkunft: "verwaltet" | "bekannt" | "fremd";
|
||||||
|
/** Die ganze Zeile, damit sich ein fremder Key uebernehmen laesst (#022). */
|
||||||
|
readonly zeile: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DateiRechte = {
|
||||||
|
/** Oktal wie `stat -c %a`, z. B. "700". */
|
||||||
|
readonly sshVerzeichnis: string | null;
|
||||||
|
readonly datei: string | null;
|
||||||
|
readonly eigentuemerPasst: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AbgleichErgebnis = {
|
||||||
|
readonly befunde: Befund[];
|
||||||
|
readonly eintraege: IstEintrag[];
|
||||||
|
/** Fingerprints des Solls, die im Block korrekt stehen. */
|
||||||
|
readonly vorhanden: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function beschreibbarFuerAndere(modus: string | null): boolean {
|
||||||
|
if (!modus) return false;
|
||||||
|
const zahl = Number.parseInt(modus, 8);
|
||||||
|
return Number.isFinite(zahl) && (zahl & 0o022) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeileText(zeile: SchluesselZeile): string {
|
||||||
|
return zeile.roh.endsWith("\r") ? zeile.roh.slice(0, -1) : zeile.roh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorizedKeysAbgleichen(
|
||||||
|
inhalt: string | null,
|
||||||
|
soll: readonly SollEintrag[],
|
||||||
|
bekannteFingerprints: ReadonlySet<string>,
|
||||||
|
rechte?: DateiRechte,
|
||||||
|
): AbgleichErgebnis {
|
||||||
|
const befunde: Befund[] = [];
|
||||||
|
const datei = authorizedKeysLesen(inhalt ?? "");
|
||||||
|
|
||||||
|
if (datei.block.art === "beschaedigt") {
|
||||||
|
befunde.push({ art: "DATEI_GEAENDERT", text: `Verwalteter Block beschädigt: ${datei.block.grund}` });
|
||||||
|
}
|
||||||
|
if (rechte) {
|
||||||
|
if (beschreibbarFuerAndere(rechte.sshVerzeichnis)) {
|
||||||
|
befunde.push({ art: "RECHTE", text: `~/.ssh hat die Rechte ${rechte.sshVerzeichnis} (erwartet 700).` });
|
||||||
|
}
|
||||||
|
if (beschreibbarFuerAndere(rechte.datei)) {
|
||||||
|
befunde.push({ art: "RECHTE", text: `authorized_keys hat die Rechte ${rechte.datei} (erwartet 600).` });
|
||||||
|
}
|
||||||
|
if (!rechte.eigentuemerPasst) {
|
||||||
|
befunde.push({ art: "RECHTE", text: "~/.ssh oder authorized_keys gehört nicht dem Benutzer." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sollNachFingerprint = new Map(soll.map((e) => [e.fingerprint, e]));
|
||||||
|
const schluessel = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel");
|
||||||
|
const imBlock = schluessel.filter((z) => z.imBlock);
|
||||||
|
|
||||||
|
// Erwartete Zeile im Block, genau wie blockSetzen sie schreibt – dann faellt auch eine
|
||||||
|
// geaenderte Option auf.
|
||||||
|
const erwartet = new Map<string, string>();
|
||||||
|
if (soll.length) {
|
||||||
|
const muster = authorizedKeysLesen(blockSetzen("", soll).inhalt).zeilen.filter(
|
||||||
|
(z): z is SchluesselZeile => z.art === "schluessel",
|
||||||
|
);
|
||||||
|
for (const zeile of muster) erwartet.set(zeile.fingerprint, zeileText(zeile));
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden: string[] = [];
|
||||||
|
for (const eintrag of soll) {
|
||||||
|
const treffer = imBlock.find((z) => z.fingerprint === eintrag.fingerprint);
|
||||||
|
if (!treffer) {
|
||||||
|
befunde.push({
|
||||||
|
art: "FEHLENDER_SCHLUESSEL",
|
||||||
|
fingerprint: eintrag.fingerprint,
|
||||||
|
text: "Zugewiesener Schlüssel fehlt im verwalteten Block.",
|
||||||
|
});
|
||||||
|
} else if (zeileText(treffer) !== erwartet.get(eintrag.fingerprint)) {
|
||||||
|
befunde.push({
|
||||||
|
art: "ZUSAETZLICH_IM_BLOCK",
|
||||||
|
fingerprint: eintrag.fingerprint,
|
||||||
|
text: "Schlüssel steht mit anderen Optionen im Block als zugewiesen.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
vorhanden.push(eintrag.fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const zeile of imBlock) {
|
||||||
|
if (!sollNachFingerprint.has(zeile.fingerprint)) {
|
||||||
|
befunde.push({
|
||||||
|
art: "ZUSAETZLICH_IM_BLOCK",
|
||||||
|
fingerprint: zeile.fingerprint,
|
||||||
|
text: "Im verwalteten Block steht ein Schlüssel, der hier nicht zugewiesen ist.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const zeile of schluessel.filter((z) => !z.imBlock)) {
|
||||||
|
if (!bekannteFingerprints.has(zeile.fingerprint)) {
|
||||||
|
befunde.push({
|
||||||
|
art: "UNBEKANNTER_SCHLUESSEL",
|
||||||
|
fingerprint: zeile.fingerprint,
|
||||||
|
text: `Unbekannter Schlüssel außerhalb des Blocks${zeile.kommentar ? ` („${zeile.kommentar}“)` : ""}.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eintraege: IstEintrag[] = schluessel.map((zeile) => ({
|
||||||
|
fingerprint: zeile.fingerprint,
|
||||||
|
typ: zeile.typ,
|
||||||
|
kommentar: zeile.kommentar,
|
||||||
|
optionen: zeile.optionen,
|
||||||
|
imBlock: zeile.imBlock,
|
||||||
|
herkunft: sollNachFingerprint.has(zeile.fingerprint)
|
||||||
|
? "verwaltet"
|
||||||
|
: bekannteFingerprints.has(zeile.fingerprint)
|
||||||
|
? "bekannt"
|
||||||
|
: "fremd",
|
||||||
|
zeile: zeileText(zeile),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Doppelte Befunde (derselbe Key zweimal ausserhalb) nur einmal.
|
||||||
|
const gesehen = new Set<string>();
|
||||||
|
const eindeutig = befunde.filter((b) => {
|
||||||
|
const schluesselText = `${b.art}\u0000${b.fingerprint ?? b.text}`;
|
||||||
|
if (gesehen.has(schluesselText)) return false;
|
||||||
|
gesehen.add(schluesselText);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return { befunde: eindeutig, eintraege, vorhanden };
|
||||||
|
}
|
||||||
@@ -197,6 +197,12 @@ export function blockSetzen(
|
|||||||
inhalt: string,
|
inhalt: string,
|
||||||
soll: readonly SollEintrag[],
|
soll: readonly SollEintrag[],
|
||||||
pflicht: readonly string[] = [],
|
pflicht: readonly string[] = [],
|
||||||
|
/**
|
||||||
|
* Fingerprints, deren Zeilen ausserhalb des Blocks entfernt werden (#022): nur fuer Keys,
|
||||||
|
* die ein Mensch ausdruecklich in die Verwaltung uebernommen hat. Sonst gilt: fremde
|
||||||
|
* Zeilen bleiben bytegenau stehen.
|
||||||
|
*/
|
||||||
|
ausserhalbEntfernen: readonly string[] = [],
|
||||||
): BlockErgebnis {
|
): BlockErgebnis {
|
||||||
const datei = authorizedKeysLesen(inhalt);
|
const datei = authorizedKeysLesen(inhalt);
|
||||||
if (datei.block.art === "beschaedigt") {
|
if (datei.block.art === "beschaedigt") {
|
||||||
@@ -227,11 +233,18 @@ export function blockSetzen(
|
|||||||
|
|
||||||
let rohNeu: string[];
|
let rohNeu: string[];
|
||||||
let endetMitZeilenende = datei.endetMitZeilenende;
|
let endetMitZeilenende = datei.endetMitZeilenende;
|
||||||
|
const entfernen = new Set(ausserhalbEntfernen);
|
||||||
|
const bleibt = (z: Zeile) => !(z.art === "schluessel" && !z.imBlock && entfernen.has(z.fingerprint));
|
||||||
const roh = datei.zeilen.map((z) => z.roh);
|
const roh = datei.zeilen.map((z) => z.roh);
|
||||||
|
const rohOhne = (von: number, bis?: number) =>
|
||||||
|
datei.zeilen
|
||||||
|
.slice(von, bis)
|
||||||
|
.filter(bleibt)
|
||||||
|
.map((z) => z.roh);
|
||||||
if (datei.block.art === "ok") {
|
if (datei.block.art === "ok") {
|
||||||
rohNeu = [...roh.slice(0, datei.block.anfang), ...neuerBlock, ...roh.slice(datei.block.ende + 1)];
|
rohNeu = [...rohOhne(0, datei.block.anfang), ...neuerBlock, ...rohOhne(datei.block.ende + 1)];
|
||||||
} else {
|
} else {
|
||||||
rohNeu = [...roh, ...neuerBlock];
|
rohNeu = [...rohOhne(0), ...neuerBlock];
|
||||||
// Ein neuer Block beginnt auf einer eigenen Zeile; die bisher letzte Zeile bekommt dafuer
|
// Ein neuer Block beginnt auf einer eigenen Zeile; die bisher letzte Zeile bekommt dafuer
|
||||||
// ihre fehlende Zeilenschaltung – die einzige Aenderung ausserhalb des Blocks.
|
// ihre fehlende Zeilenschaltung – die einzige Aenderung ausserhalb des Blocks.
|
||||||
endetMitZeilenende = true;
|
endetMitZeilenende = true;
|
||||||
@@ -241,7 +254,9 @@ export function blockSetzen(
|
|||||||
const hinweise: string[] = [];
|
const hinweise: string[] = [];
|
||||||
const ausserhalb = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel" && !z.imBlock);
|
const ausserhalb = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel" && !z.imBlock);
|
||||||
for (const eintrag of eintraege) {
|
for (const eintrag of eintraege) {
|
||||||
const doppelt = ausserhalb.find((z) => z.fingerprint === eintrag.fingerprint);
|
const doppelt = ausserhalb.find(
|
||||||
|
(z) => z.fingerprint === eintrag.fingerprint && !entfernen.has(z.fingerprint),
|
||||||
|
);
|
||||||
if (doppelt) {
|
if (doppelt) {
|
||||||
hinweise.push(
|
hinweise.push(
|
||||||
`${eintrag.fingerprint} steht zusätzlich außerhalb des Blocks${doppelt.optionen ? "" : " – dort ohne Einschränkungen"}.`,
|
`${eintrag.fingerprint} steht zusätzlich außerhalb des Blocks${doppelt.optionen ? "" : " – dort ohne Einschränkungen"}.`,
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { csvDatei } from "@/lib/csv";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zuweisungsmatrix Schluessel × Host (#025). Rein: bekommt die Zeilen aus der Datenbank und
|
||||||
|
* baut daraus Matrix, Zaehler und CSV – so laesst sich die Gruppenaufloesung ohne Datenbank
|
||||||
|
* testen, und Seite und CSV-Export sehen garantiert dasselbe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MatrixZustand =
|
||||||
|
"GEPLANT" | "AUSGEROLLT" | "ABWEICHUNG" | "ENTFERNEN" | "ENTFERNT" | "FEHLGESCHLAGEN";
|
||||||
|
export type MatrixKategorie = "PERSON" | "DIENST" | "DEPLOYMENT";
|
||||||
|
|
||||||
|
export type MatrixSchluessel = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly fingerprint: string;
|
||||||
|
readonly kategorie: MatrixKategorie;
|
||||||
|
readonly person: { readonly id: string; readonly name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatrixHost = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly gruppenIds: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatrixGruppe = { readonly id: string; readonly name: string };
|
||||||
|
|
||||||
|
export type PublicZeile = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly schluesselId: string;
|
||||||
|
readonly hostId: string | null;
|
||||||
|
readonly hostgruppeId: string | null;
|
||||||
|
readonly zielbenutzer: string;
|
||||||
|
readonly optionen: string | null;
|
||||||
|
readonly zustand: MatrixZustand;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivatZeile = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly schluesselId: string;
|
||||||
|
readonly hostId: string;
|
||||||
|
readonly zielbenutzer: string;
|
||||||
|
readonly pfad: string;
|
||||||
|
readonly zustand: MatrixZustand;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatrixDaten = {
|
||||||
|
readonly schluessel: readonly MatrixSchluessel[];
|
||||||
|
readonly hosts: readonly MatrixHost[];
|
||||||
|
readonly gruppen: readonly MatrixGruppe[];
|
||||||
|
readonly oeffentlich: readonly PublicZeile[];
|
||||||
|
readonly privat: readonly PrivatZeile[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MatrixFilter = {
|
||||||
|
readonly gruppeId?: string;
|
||||||
|
readonly kategorie?: string;
|
||||||
|
readonly personId?: string;
|
||||||
|
readonly q?: string;
|
||||||
|
/** Nur Zeilen und Spalten, in denen mindestens eine Zuweisung steht. */
|
||||||
|
readonly nurBelegt?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** „P“ direkt, „P·G“ ueber eine Hostgruppe, „K“ Private Key – Kurzformen fuer die Zelle. */
|
||||||
|
export type Kennung = "P" | "P·G" | "K";
|
||||||
|
|
||||||
|
export type ZellenEintrag = {
|
||||||
|
readonly verwendungId: string;
|
||||||
|
readonly art: "public" | "privat";
|
||||||
|
readonly kennung: Kennung;
|
||||||
|
readonly gruppe: MatrixGruppe | null;
|
||||||
|
readonly zielbenutzer: string;
|
||||||
|
readonly optionen: string | null;
|
||||||
|
readonly pfad: string | null;
|
||||||
|
readonly zustand: MatrixZustand;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Zelle = {
|
||||||
|
readonly schluesselId: string;
|
||||||
|
readonly hostId: string;
|
||||||
|
readonly eintraege: readonly ZellenEintrag[];
|
||||||
|
/** Der dringendste Zustand der Eintraege – er bestimmt Punkt und Kurztext. */
|
||||||
|
readonly zustand: MatrixZustand;
|
||||||
|
readonly kennungen: readonly Kennung[];
|
||||||
|
readonly zielbenutzer: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Matrix = {
|
||||||
|
readonly zeilen: readonly MatrixSchluessel[];
|
||||||
|
readonly spalten: readonly MatrixHost[];
|
||||||
|
/** Schluessel `${schluesselId}:${hostId}`; leere Zellen fehlen. */
|
||||||
|
readonly zellen: ReadonlyMap<string, Zelle>;
|
||||||
|
readonly zaehler: {
|
||||||
|
readonly schluessel: number;
|
||||||
|
readonly hosts: number;
|
||||||
|
readonly zuweisungen: number;
|
||||||
|
readonly nichtSynchron: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ZUSTAND_TEXT: Record<MatrixZustand, string> = {
|
||||||
|
GEPLANT: "noch nicht ausgerollt",
|
||||||
|
AUSGEROLLT: "synchron",
|
||||||
|
ABWEICHUNG: "Drift",
|
||||||
|
ENTFERNEN: "wird entfernt",
|
||||||
|
ENTFERNT: "entfernt",
|
||||||
|
FEHLGESCHLAGEN: "fehlgeschlagen",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kurztext fuer die enge Zelle; der volle Text steht in `title` und fuer Screenreader. */
|
||||||
|
export const ZUSTAND_KURZ: Record<MatrixZustand, string> = {
|
||||||
|
GEPLANT: "geplant",
|
||||||
|
AUSGEROLLT: "sync",
|
||||||
|
ABWEICHUNG: "Drift",
|
||||||
|
ENTFERNEN: "entf.",
|
||||||
|
ENTFERNT: "entfernt",
|
||||||
|
FEHLGESCHLAGEN: "Fehler",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KENNUNG_TEXT: Record<Kennung, string> = {
|
||||||
|
P: "Public Key direkt",
|
||||||
|
"P·G": "Public Key über Hostgruppe",
|
||||||
|
K: "Private Key",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Was zuerst ins Auge fallen muss: Fehler und Drift vor offener Arbeit vor „alles gut“. */
|
||||||
|
const DRINGLICHKEIT: Record<MatrixZustand, number> = {
|
||||||
|
FEHLGESCHLAGEN: 5,
|
||||||
|
ABWEICHUNG: 4,
|
||||||
|
ENTFERNEN: 3,
|
||||||
|
GEPLANT: 2,
|
||||||
|
AUSGEROLLT: 1,
|
||||||
|
ENTFERNT: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function zellenSchluessel(schluesselId: string, hostId: string): string {
|
||||||
|
return `${schluesselId}:${hostId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enthaelt(text: string, q: string): boolean {
|
||||||
|
return text.toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KENNUNG_REIHENFOLGE: readonly Kennung[] = ["P", "P·G", "K"];
|
||||||
|
|
||||||
|
export function matrixAufbauen(daten: MatrixDaten, filter: MatrixFilter = {}): Matrix {
|
||||||
|
const gruppen = new Map(daten.gruppen.map((g) => [g.id, g]));
|
||||||
|
const hostsJeGruppe = new Map<string, string[]>();
|
||||||
|
for (const h of daten.hosts) {
|
||||||
|
for (const g of h.gruppenIds) hostsJeGruppe.set(g, [...(hostsJeGruppe.get(g) ?? []), h.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Eintraege je Zelle sammeln – Gruppen-Verwendungen fuer jedes Mitglied einzeln.
|
||||||
|
const sammel = new Map<string, ZellenEintrag[]>();
|
||||||
|
const hinzu = (schluesselId: string, hostId: string, eintrag: ZellenEintrag) => {
|
||||||
|
const k = zellenSchluessel(schluesselId, hostId);
|
||||||
|
sammel.set(k, [...(sammel.get(k) ?? []), eintrag]);
|
||||||
|
};
|
||||||
|
for (const v of daten.oeffentlich) {
|
||||||
|
if (v.zustand === "ENTFERNT") continue;
|
||||||
|
const gruppe = v.hostgruppeId ? (gruppen.get(v.hostgruppeId) ?? null) : null;
|
||||||
|
const ziele = v.hostId ? [v.hostId] : v.hostgruppeId ? (hostsJeGruppe.get(v.hostgruppeId) ?? []) : [];
|
||||||
|
for (const hostId of ziele) {
|
||||||
|
hinzu(v.schluesselId, hostId, {
|
||||||
|
verwendungId: v.id,
|
||||||
|
art: "public",
|
||||||
|
kennung: v.hostId ? "P" : "P·G",
|
||||||
|
gruppe: v.hostId ? null : gruppe,
|
||||||
|
zielbenutzer: v.zielbenutzer,
|
||||||
|
optionen: v.optionen,
|
||||||
|
pfad: null,
|
||||||
|
zustand: v.zustand,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const v of daten.privat) {
|
||||||
|
if (v.zustand === "ENTFERNT") continue;
|
||||||
|
hinzu(v.schluesselId, v.hostId, {
|
||||||
|
verwendungId: v.id,
|
||||||
|
art: "privat",
|
||||||
|
kennung: "K",
|
||||||
|
gruppe: null,
|
||||||
|
zielbenutzer: v.zielbenutzer,
|
||||||
|
optionen: null,
|
||||||
|
pfad: v.pfad,
|
||||||
|
zustand: v.zustand,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: Schluessel-Merkmale schraenken Zeilen ein, die Hostgruppe die Spalten.
|
||||||
|
const q = filter.q?.trim().toLowerCase() ?? "";
|
||||||
|
let zeilen = daten.schluessel.filter(
|
||||||
|
(s) =>
|
||||||
|
(!filter.kategorie || s.kategorie === filter.kategorie) &&
|
||||||
|
(!filter.personId || s.person?.id === filter.personId),
|
||||||
|
);
|
||||||
|
let spalten = daten.hosts.filter((h) => !filter.gruppeId || h.gruppenIds.includes(filter.gruppeId));
|
||||||
|
if (q) {
|
||||||
|
// Eine Suche trifft Schluessel- oder Hostnamen: wer „srv-pve“ sucht, will alle Schluessel
|
||||||
|
// auf diesen Hosts sehen, nicht eine leere Matrix, weil kein Schluessel so heisst.
|
||||||
|
const zeilenTreffer = zeilen.filter((s) => enthaelt(s.name, q) || enthaelt(s.fingerprint, q));
|
||||||
|
const spaltenTreffer = spalten.filter((h) => enthaelt(h.name, q));
|
||||||
|
zeilen = zeilenTreffer.length > 0 || spaltenTreffer.length === 0 ? zeilenTreffer : zeilen;
|
||||||
|
spalten = spaltenTreffer.length > 0 || zeilenTreffer.length === 0 ? spaltenTreffer : spalten;
|
||||||
|
}
|
||||||
|
if (filter.nurBelegt) {
|
||||||
|
const belegt = (s: string, h: string) => sammel.has(zellenSchluessel(s, h));
|
||||||
|
zeilen = zeilen.filter((s) => spalten.some((h) => belegt(s.id, h.id)));
|
||||||
|
spalten = spalten.filter((h) => zeilen.some((s) => belegt(s.id, h.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const zellen = new Map<string, Zelle>();
|
||||||
|
let zuweisungen = 0;
|
||||||
|
let nichtSynchron = 0;
|
||||||
|
for (const s of zeilen) {
|
||||||
|
for (const h of spalten) {
|
||||||
|
const k = zellenSchluessel(s.id, h.id);
|
||||||
|
const eintraege = sammel.get(k);
|
||||||
|
if (!eintraege) continue;
|
||||||
|
eintraege.sort(
|
||||||
|
(a, b) =>
|
||||||
|
KENNUNG_REIHENFOLGE.indexOf(a.kennung) - KENNUNG_REIHENFOLGE.indexOf(b.kennung) ||
|
||||||
|
a.zielbenutzer.localeCompare(b.zielbenutzer),
|
||||||
|
);
|
||||||
|
zuweisungen += eintraege.length;
|
||||||
|
nichtSynchron += eintraege.filter((e) => e.zustand !== "AUSGEROLLT").length;
|
||||||
|
zellen.set(k, {
|
||||||
|
schluesselId: s.id,
|
||||||
|
hostId: h.id,
|
||||||
|
eintraege,
|
||||||
|
zustand: eintraege.reduce<MatrixZustand>(
|
||||||
|
(schlimmster, e) =>
|
||||||
|
DRINGLICHKEIT[e.zustand] > DRINGLICHKEIT[schlimmster] ? e.zustand : schlimmster,
|
||||||
|
"ENTFERNT",
|
||||||
|
),
|
||||||
|
kennungen: KENNUNG_REIHENFOLGE.filter((kennung) => eintraege.some((e) => e.kennung === kennung)),
|
||||||
|
zielbenutzer: [...new Set(eintraege.map((e) => e.zielbenutzer))],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
zeilen,
|
||||||
|
spalten,
|
||||||
|
zellen,
|
||||||
|
zaehler: { schluessel: zeilen.length, hosts: spalten.length, zuweisungen, nichtSynchron },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter aus GET-Parametern; Unbekanntes faellt weg statt einen Fehler zu werfen. */
|
||||||
|
export function matrixFilterAus(parameter: Record<string, string | string[] | undefined>): MatrixFilter {
|
||||||
|
const einzeln = (name: string) => {
|
||||||
|
const wert = parameter[name];
|
||||||
|
return ((Array.isArray(wert) ? wert[0] : wert) ?? "").trim().slice(0, 200);
|
||||||
|
};
|
||||||
|
const kategorie = einzeln("kategorie");
|
||||||
|
return {
|
||||||
|
gruppeId: einzeln("gruppe"),
|
||||||
|
kategorie: ["PERSON", "DIENST", "DEPLOYMENT"].includes(kategorie) ? kategorie : "",
|
||||||
|
personId: einzeln("person"),
|
||||||
|
q: einzeln("q"),
|
||||||
|
nurBelegt: einzeln("belegt") === "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dieselben Filter als Query-String, z. B. fuer den CSV-Link. */
|
||||||
|
export function matrixFilterQuery(filter: MatrixFilter): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (filter.gruppeId) p.set("gruppe", filter.gruppeId);
|
||||||
|
if (filter.kategorie) p.set("kategorie", filter.kategorie);
|
||||||
|
if (filter.personId) p.set("person", filter.personId);
|
||||||
|
if (filter.q) p.set("q", filter.q);
|
||||||
|
if (filter.nurBelegt) p.set("belegt", "1");
|
||||||
|
const text = p.toString();
|
||||||
|
return text ? `?${text}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CSV_KOPF = [
|
||||||
|
"Schlüssel",
|
||||||
|
"Fingerprint",
|
||||||
|
"Host",
|
||||||
|
"Zielbenutzer",
|
||||||
|
"Art",
|
||||||
|
"Über Gruppe",
|
||||||
|
"Optionen",
|
||||||
|
"Zustand",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Eine Zeile je Zelleneintrag – Gruppen-Zuweisungen also einmal je Mitglied (#025). */
|
||||||
|
export function matrixCsv(matrix: Matrix): string {
|
||||||
|
const zeilen: string[][] = [];
|
||||||
|
for (const s of matrix.zeilen) {
|
||||||
|
for (const h of matrix.spalten) {
|
||||||
|
const zelle = matrix.zellen.get(zellenSchluessel(s.id, h.id));
|
||||||
|
for (const e of zelle?.eintraege ?? []) {
|
||||||
|
zeilen.push([
|
||||||
|
s.name,
|
||||||
|
s.fingerprint,
|
||||||
|
h.name,
|
||||||
|
e.zielbenutzer,
|
||||||
|
e.art === "public" ? "Public Key" : "Private Key",
|
||||||
|
e.gruppe?.name ?? "",
|
||||||
|
e.art === "public" ? (e.optionen ?? "") : `Pfad: ${e.pfad ?? ""}`,
|
||||||
|
ZUSTAND_TEXT[e.zustand],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return csvDatei(CSV_KOPF, zeilen);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { abgleichAnfordern, alleHostsAbgleichen } from "@/server/services/abgleich";
|
||||||
|
import { keyUebernehmen } from "@/server/services/uebernahme";
|
||||||
|
|
||||||
|
/** Abgleich anstossen und vorhandene Keys uebernehmen (#021, #022). */
|
||||||
|
|
||||||
|
export async function abgleichAktion(hostId: string): Promise<{ vorgangId?: string; fehler?: string }> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||||
|
return { vorgangId: await abgleichAnfordern(z.string().min(1).max(64).parse(hostId), benutzer.id) };
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function alleAbgleichenAktion(): Promise<{ anzahl?: number; fehler?: string }> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||||
|
return { anzahl: await alleHostsAbgleichen(benutzer.id) };
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uebernehmenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
const verwalten = formular.get("verwalten") === "ja";
|
||||||
|
const hostId = textfeld(formular, "hostId") ?? "";
|
||||||
|
try {
|
||||||
|
// Aufnehmen ist Schluesselverwaltung; in den Block holen ist zusaetzlich Ausrollen.
|
||||||
|
const { benutzer } = await rechtErforderlich(verwalten ? "publicKeyAusrollen" : "schluesselVerwalten");
|
||||||
|
await keyUebernehmen(
|
||||||
|
{
|
||||||
|
hostId,
|
||||||
|
zielbenutzer: textfeld(formular, "zielbenutzer") ?? "",
|
||||||
|
fingerprint: textfeld(formular, "fingerprint") ?? "",
|
||||||
|
name: (textfeld(formular, "name") ?? "").trim(),
|
||||||
|
kategorie: textfeld(formular, "kategorie") === "DIENST" ? "DIENST" : "PERSON",
|
||||||
|
personId: textfeld(formular, "personId") ?? null,
|
||||||
|
verwalten,
|
||||||
|
},
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath(`/hosts/${hostId}`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { ZielkontoFehler } from "@/lib/ssh/zielkonto";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { akteurVon } from "@/server/actions/formular";
|
||||||
|
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { HostFehler } from "@/server/services/hosts";
|
||||||
|
import { erzeugenAnfordern } from "@/server/services/schluessel";
|
||||||
|
import {
|
||||||
|
VerwendungsFehler,
|
||||||
|
privateVerwendungAnlegen,
|
||||||
|
publicVerwendungAnlegen,
|
||||||
|
} from "@/server/services/verwendungen";
|
||||||
|
import { zielkontoAnfordern } from "@/server/services/zielkonten";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schritte des Host-zu-Host-Assistenten (#020). Jeder Schritt ist eine eigene Aktion, die
|
||||||
|
* Oberflaeche verkettet sie und wartet dazwischen auf den Worker. So bleibt jede Aktion
|
||||||
|
* duenn, und jeder Schritt hat seine eigene Rechtepruefung.
|
||||||
|
*/
|
||||||
|
export type SchrittAntwort = {
|
||||||
|
readonly vorgangId?: string;
|
||||||
|
readonly privatVerwendungId?: string;
|
||||||
|
readonly fehler?: string;
|
||||||
|
/** Kritischer Schritt ohne frische Bestaetigung: dorthin, dann weiter. */
|
||||||
|
readonly bestaetigen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function alsAntwort(fehler: unknown): SchrittAntwort {
|
||||||
|
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
|
||||||
|
if (
|
||||||
|
fehler instanceof RechteFehler ||
|
||||||
|
fehler instanceof VerwendungsFehler ||
|
||||||
|
fehler instanceof ZielkontoFehler ||
|
||||||
|
fehler instanceof HostFehler
|
||||||
|
) {
|
||||||
|
return { fehler: fehler.message };
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = z.string().min(1).max(64);
|
||||||
|
|
||||||
|
export async function assistentErzeugenAktion(eingabe: {
|
||||||
|
name: string;
|
||||||
|
vorlageId: string | null;
|
||||||
|
ablaufAm: string | null;
|
||||||
|
}): Promise<SchrittAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||||
|
return {
|
||||||
|
vorgangId: await erzeugenAnfordern(
|
||||||
|
{
|
||||||
|
name: eingabe.name,
|
||||||
|
vorlageId: eingabe.vorlageId,
|
||||||
|
ablaufAm: eingabe.ablaufAm,
|
||||||
|
tags: ["host-zu-host"],
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
akteurVon(benutzer),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assistentZielkontoAktion(eingabe: {
|
||||||
|
hostId: string;
|
||||||
|
benutzer: string;
|
||||||
|
modus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||||
|
befehle: string[];
|
||||||
|
}): Promise<SchrittAntwort> {
|
||||||
|
try {
|
||||||
|
// Ein neuer Benutzer mit sudo ist so viel wert wie root: Admin mit frischer Bestaetigung.
|
||||||
|
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
|
||||||
|
return {
|
||||||
|
vorgangId: await zielkontoAnfordern({ ...eingabe, hostId: id.parse(eingabe.hostId) }, benutzer),
|
||||||
|
};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assistentZuweisenAktion(eingabe: {
|
||||||
|
schluesselId: string;
|
||||||
|
quelle: { hostId: string; benutzer: string; pfad: string };
|
||||||
|
ziel: { hostId: string; benutzer: string; optionen: string | null };
|
||||||
|
ssh: { alias: string; hostName: string; port: number; knownHosts: boolean };
|
||||||
|
}): Promise<SchrittAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||||
|
const akteur = akteurVon(benutzer);
|
||||||
|
const privatVerwendungId = await privateVerwendungAnlegen(
|
||||||
|
{
|
||||||
|
schluesselId: id.parse(eingabe.schluesselId),
|
||||||
|
hostId: id.parse(eingabe.quelle.hostId),
|
||||||
|
zielbenutzer: eingabe.quelle.benutzer,
|
||||||
|
pfad: eingabe.quelle.pfad,
|
||||||
|
sshConfig: {
|
||||||
|
alias: eingabe.ssh.alias,
|
||||||
|
hostName: eingabe.ssh.hostName,
|
||||||
|
benutzer: eingabe.ziel.benutzer,
|
||||||
|
port: eingabe.ssh.port,
|
||||||
|
},
|
||||||
|
knownHostsFuer: eingabe.ssh.knownHosts ? [id.parse(eingabe.ziel.hostId)] : [],
|
||||||
|
},
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
await publicVerwendungAnlegen(
|
||||||
|
{
|
||||||
|
schluesselId: eingabe.schluesselId,
|
||||||
|
hostId: id.parse(eingabe.ziel.hostId),
|
||||||
|
zielbenutzer: eingabe.ziel.benutzer,
|
||||||
|
optionen: eingabe.ziel.optionen,
|
||||||
|
},
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
return { privatVerwendungId };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { akteurVon } from "@/server/actions/formular";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation";
|
||||||
|
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gefuehrte Rotation (#023): je Schritt eine duenne Aktion, die Oberflaeche verkettet sie.
|
||||||
|
* Rotieren heisst einen Schluessel erzeugen und ausrollen: `schluesselVerwalten` und
|
||||||
|
* `publicKeyAusrollen`; fuer Private-Key-Zuweisungen zusaetzlich `privateKeyAusrollen` (Service).
|
||||||
|
*/
|
||||||
|
export type RotationsAntwort = { readonly vorgangId?: string; readonly fehler?: string };
|
||||||
|
|
||||||
|
function alsAntwort(fehler: unknown): RotationsAntwort {
|
||||||
|
if (
|
||||||
|
fehler instanceof RechteFehler ||
|
||||||
|
fehler instanceof BestaetigungFehler ||
|
||||||
|
fehler instanceof VerwendungsFehler ||
|
||||||
|
fehler instanceof FachFehler
|
||||||
|
) {
|
||||||
|
return { fehler: fehler.message };
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = z.string().min(1).max(64);
|
||||||
|
|
||||||
|
async function berechtigt() {
|
||||||
|
await rechtErforderlich("schluesselVerwalten");
|
||||||
|
return rechtErforderlich("publicKeyAusrollen");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotationStartenAktion(schluesselId: string): Promise<RotationsAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await berechtigt();
|
||||||
|
return { vorgangId: await rotationStarten(id.parse(schluesselId), akteurVon(benutzer)) };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotationUebernehmenAktion(schluesselId: string): Promise<RotationsAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await berechtigt();
|
||||||
|
await rotationUebernehmen(id.parse(schluesselId), akteurVon(benutzer), {
|
||||||
|
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
|
});
|
||||||
|
revalidatePath(`/schluessel/${schluesselId}`);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotationAltWiderrufenAktion(schluesselId: string): Promise<RotationsAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await berechtigt();
|
||||||
|
await rotationAltWiderrufen(id.parse(schluesselId), akteurVon(benutzer), {
|
||||||
|
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
|
});
|
||||||
|
revalidatePath(`/schluessel/${schluesselId}`);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,7 +74,11 @@ export async function publicEntfernenAktion(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AusrollAuswahl = { readonly schluesselId: string } | { readonly hostId: string };
|
export type AusrollAuswahl =
|
||||||
|
| { readonly schluesselId: string }
|
||||||
|
| { readonly hostId: string }
|
||||||
|
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
|
||||||
|
| { readonly schluesselIds: readonly string[] };
|
||||||
|
|
||||||
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
|
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
|
||||||
try {
|
try {
|
||||||
@@ -84,6 +88,8 @@ export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<Vorgan
|
|||||||
vorgangId: await trockenlaufStarten(
|
vorgangId: await trockenlaufStarten(
|
||||||
"schluesselId" in auswahl
|
"schluesselId" in auswahl
|
||||||
? { schluesselId: id.parse(auswahl.schluesselId) }
|
? { schluesselId: id.parse(auswahl.schluesselId) }
|
||||||
|
: "schluesselIds" in auswahl
|
||||||
|
? { schluesselIds: z.array(id).min(1).max(200).parse(auswahl.schluesselIds) }
|
||||||
: { hostId: id.parse(auswahl.hostId) },
|
: { hostId: id.parse(auswahl.hostId) },
|
||||||
akteurVon(benutzer),
|
akteurVon(benutzer),
|
||||||
// Private-Key-Ziele nur fuer Admins – ein Operator rollt so nie einen Private Key aus.
|
// Private-Key-Ziele nur fuer Admins – ein Operator rollt so nie einen Private Key aus.
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { akteurVon } from "@/server/actions/formular";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||||
|
import {
|
||||||
|
widerrufAbschliessen,
|
||||||
|
widerrufEinleiten,
|
||||||
|
widerrufZielkontenEntfernen,
|
||||||
|
type AbschlussErgebnis,
|
||||||
|
} from "@/server/services/widerruf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widerruf und Massenwiderruf (#024). Duenn: Recht, Eingabe, Service.
|
||||||
|
*
|
||||||
|
* Rechte: einen Public Key entfernen darf, wer ausrollen darf; sobald Private Keys betroffen
|
||||||
|
* sind, nur ein Admin (prueft der Service mit `privatErlaubt`). Mehrere Schluessel auf einmal
|
||||||
|
* (Massenwiderruf) und das Entfernen von Zielkonten verlangen Admin mit frischer Bestaetigung.
|
||||||
|
*/
|
||||||
|
export type WiderrufAntwort = {
|
||||||
|
readonly fehler?: string;
|
||||||
|
/** Frische Bestaetigung noetig: die Oberflaeche verlinkt /bestaetigen. */
|
||||||
|
readonly bestaetigen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function alsAntwort(fehler: unknown): WiderrufAntwort {
|
||||||
|
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
|
||||||
|
if (fehler instanceof RechteFehler || fehler instanceof VerwendungsFehler)
|
||||||
|
return { fehler: fehler.message };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idListe = z.array(z.string().min(1).max(64)).min(1).max(200);
|
||||||
|
|
||||||
|
function pfadeNeuLaden(ids: readonly string[]) {
|
||||||
|
revalidatePath("/schluessel");
|
||||||
|
for (const id of ids) revalidatePath(`/schluessel/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function widerrufEinleitenAktion(schluesselIds: string[]): Promise<WiderrufAntwort> {
|
||||||
|
try {
|
||||||
|
const ids = idListe.parse(schluesselIds);
|
||||||
|
const { benutzer } = await rechtErforderlich(ids.length > 1 ? "massenwiderruf" : "publicKeyAusrollen");
|
||||||
|
await widerrufEinleiten(ids, akteurVon(benutzer), {
|
||||||
|
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
|
});
|
||||||
|
pfadeNeuLaden(ids);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function widerrufZielkontenAktion(
|
||||||
|
schluesselIds: string[],
|
||||||
|
zielkontoIds: string[],
|
||||||
|
): Promise<WiderrufAntwort & { vorgaenge?: { id: string; beschriftung: string }[] }> {
|
||||||
|
try {
|
||||||
|
const ids = idListe.parse(schluesselIds);
|
||||||
|
const konten = idListe.parse(zielkontoIds);
|
||||||
|
// Ein Benutzer samt Home verschwindet: Admin mit frischer Bestaetigung (#018).
|
||||||
|
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
|
||||||
|
return { vorgaenge: await widerrufZielkontenEntfernen(ids, konten, akteurVon(benutzer)) };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function widerrufAbschliessenAktion(
|
||||||
|
schluesselIds: string[],
|
||||||
|
): Promise<WiderrufAntwort & { ergebnisse?: AbschlussErgebnis[] }> {
|
||||||
|
try {
|
||||||
|
const ids = idListe.parse(schluesselIds);
|
||||||
|
// Abschliessen setzt nur den Status nach dem, was die Datenbank belegt – kein Host-Zugriff.
|
||||||
|
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||||
|
const ergebnisse = await widerrufAbschliessen(ids, akteurVon(benutzer));
|
||||||
|
pfadeNeuLaden(ids);
|
||||||
|
return { ergebnisse };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { IstEintrag } from "@/lib/ssh/abgleich";
|
||||||
|
|
||||||
|
/** Ist-Zustand und offene Abweichungen eines Hosts (#021) – nur oeffentliche Angaben. */
|
||||||
|
export async function istZustand(hostId: string) {
|
||||||
|
const [beobachtungen, abweichungen, personen] = await Promise.all([
|
||||||
|
prisma.beobachtung.findMany({
|
||||||
|
where: { hostId },
|
||||||
|
select: { zielbenutzer: true, beobachtetAm: true, eintraege: true },
|
||||||
|
orderBy: { zielbenutzer: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.abweichung.findMany({
|
||||||
|
where: { hostId, behobenAm: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
art: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
fingerprint: true,
|
||||||
|
details: true,
|
||||||
|
eroeffnetAm: true,
|
||||||
|
},
|
||||||
|
orderBy: { eroeffnetAm: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.person.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
beobachtungen: beobachtungen.map((b) => ({
|
||||||
|
zielbenutzer: b.zielbenutzer,
|
||||||
|
beobachtetAm: b.beobachtetAm,
|
||||||
|
eintraege: b.eintraege as unknown as IstEintrag[],
|
||||||
|
})),
|
||||||
|
abweichungen: abweichungen.map((a) => ({
|
||||||
|
...a,
|
||||||
|
text:
|
||||||
|
((a.details ?? {}) as { text?: string; erwartet?: string }).text ??
|
||||||
|
(a.art === "HOSTKEY_GEAENDERT" ? "Host-Key hat sich geändert." : a.art),
|
||||||
|
})),
|
||||||
|
personen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IstZustand = Awaited<ReturnType<typeof istZustand>>;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/** Was der Host-zu-Host-Assistent zur Auswahl braucht (#020) – nur oeffentliche Angaben. */
|
||||||
|
export async function assistentDaten() {
|
||||||
|
const [hosts, vorlagen] = await Promise.all([
|
||||||
|
prisma.host.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
adresse: true,
|
||||||
|
port: true,
|
||||||
|
bootstrapAm: true,
|
||||||
|
hostSchluessel: { where: { status: "VERTRAUT" }, select: { algorithmus: true, fingerprint: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.vorlage.findMany({
|
||||||
|
where: { archiviertAm: null, kategorie: "DIENST" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
beschreibung: true,
|
||||||
|
standardLaufzeitTage: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
zielbenutzerAnlegen: true,
|
||||||
|
sudoModus: true,
|
||||||
|
sudoBefehle: true,
|
||||||
|
authorizedKeysOptionen: true,
|
||||||
|
sshConfigAlias: true,
|
||||||
|
knownHostsEintragen: true,
|
||||||
|
},
|
||||||
|
orderBy: [{ eingebaut: "desc" }, { name: "asc" }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
hosts: hosts
|
||||||
|
.filter((h) => h.bootstrapAm !== null)
|
||||||
|
.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port, hostKeys: h.hostSchluessel })),
|
||||||
|
vorlagen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssistentDaten = Awaited<ReturnType<typeof assistentDaten>>;
|
||||||
@@ -23,6 +23,7 @@ export const SCHLUESSEL_LISTE_SELECT = {
|
|||||||
ablaufAm: true,
|
ablaufAm: true,
|
||||||
masterKeyVersion: true,
|
masterKeyVersion: true,
|
||||||
passphraseGeschuetzt: true,
|
passphraseGeschuetzt: true,
|
||||||
|
widerrufEingeleitetAm: true,
|
||||||
person: { select: { id: true, name: true } },
|
person: { select: { id: true, name: true } },
|
||||||
_count: {
|
_count: {
|
||||||
select: {
|
select: {
|
||||||
@@ -50,12 +51,15 @@ export const SCHLUESSEL_DETAIL_SELECT = {
|
|||||||
tags: true,
|
tags: true,
|
||||||
ablaufAm: true,
|
ablaufAm: true,
|
||||||
zuletztVerwendetAm: true,
|
zuletztVerwendetAm: true,
|
||||||
|
widerrufEingeleitetAm: true,
|
||||||
angelegtAm: true,
|
angelegtAm: true,
|
||||||
personId: true,
|
personId: true,
|
||||||
vorlageId: true,
|
vorlageId: true,
|
||||||
person: { select: { id: true, name: true } },
|
person: { select: { id: true, name: true } },
|
||||||
vorlage: { select: { id: true, name: true } },
|
vorlage: { select: { id: true, name: true } },
|
||||||
erstelltVon: { select: { name: true } },
|
erstelltVon: { select: { name: true } },
|
||||||
|
vorgaenger: { select: { id: true, name: true, status: true } },
|
||||||
|
nachfolger: { select: { id: true, name: true, status: true } },
|
||||||
publicVerwendungen: {
|
publicVerwendungen: {
|
||||||
where: { zustand: { not: "ENTFERNT" } },
|
where: { zustand: { not: "ENTFERNT" } },
|
||||||
orderBy: { angelegtAm: "asc" },
|
orderBy: { angelegtAm: "asc" },
|
||||||
@@ -101,13 +105,34 @@ function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
|
|||||||
return STATI.find((s) => s === wert);
|
return STATI.find((s) => s === wert);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heute 00:00 UTC nach dem Kalender in Berlin – Ablaufdaten liegen als UTC-Mitternacht ihres
|
||||||
|
* Tages in der Datenbank, und `ablaufZustand()` rechnet ebenfalls mit dem Berliner Datum.
|
||||||
|
*/
|
||||||
|
function heute(jetzt = new Date()): Date {
|
||||||
|
const tag = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" }).format(jetzt);
|
||||||
|
return new Date(`${tag}T00:00:00Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status als Filter (#024): ein aktiver Schluessel mit Ablaufdatum in der Vergangenheit gilt als
|
||||||
|
* abgelaufen, auch wenn niemand den Status von Hand gesetzt hat. Automatisch entfernt wird nichts.
|
||||||
|
*/
|
||||||
|
function statusFilter(status: SchluesselStatus | undefined): Prisma.SchluesselWhereInput {
|
||||||
|
if (status === "AKTIV") return { status, OR: [{ ablaufAm: null }, { ablaufAm: { gte: heute() } }] };
|
||||||
|
if (status === "ABGELAUFEN") {
|
||||||
|
return { OR: [{ status }, { status: "AKTIV", ablaufAm: { lt: heute() } }] };
|
||||||
|
}
|
||||||
|
return status ? { status } : {};
|
||||||
|
}
|
||||||
|
|
||||||
export async function schluesselListe(filter: SchluesselFilter = {}) {
|
export async function schluesselListe(filter: SchluesselFilter = {}) {
|
||||||
const q = filter.q?.trim().slice(0, 100);
|
const q = filter.q?.trim().slice(0, 100);
|
||||||
const kategorie = alsKategorie(filter.kategorie);
|
const kategorie = alsKategorie(filter.kategorie);
|
||||||
const status = alsStatus(filter.status);
|
const status = alsStatus(filter.status);
|
||||||
const where: Prisma.SchluesselWhereInput = {
|
const where: Prisma.SchluesselWhereInput = {
|
||||||
...(kategorie ? { kategorie } : {}),
|
...(kategorie ? { kategorie } : {}),
|
||||||
...(status ? { status } : {}),
|
AND: [statusFilter(status)],
|
||||||
...(q
|
...(q
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -166,3 +191,44 @@ export async function vorlagenAuswahl() {
|
|||||||
select: { id: true, name: true, standardLaufzeitTage: true },
|
select: { id: true, name: true, standardLaufzeitTage: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Rotationskette (#023)
|
||||||
|
|
||||||
|
export type KettenGlied = { readonly id: string; readonly name: string; readonly status: SchluesselStatus };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die ganze Rotationskette eines Schluessels, aeltester zuerst. Begrenzt, damit eine (durch
|
||||||
|
* Fehler entstandene) Schleife die Seite nicht aufhaengt.
|
||||||
|
*/
|
||||||
|
export async function rotationsKette(id: string, maximal = 25): Promise<KettenGlied[]> {
|
||||||
|
const glied = (schluesselId: string) =>
|
||||||
|
prisma.schluessel.findUnique({
|
||||||
|
where: { id: schluesselId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
vorgaengerId: true,
|
||||||
|
nachfolger: { select: { id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const start = await glied(id);
|
||||||
|
if (!start) return [];
|
||||||
|
const kette: KettenGlied[] = [{ id: start.id, name: start.name, status: start.status }];
|
||||||
|
const gesehen = new Set([start.id]);
|
||||||
|
for (let davor = start.vorgaengerId; davor && kette.length < maximal && !gesehen.has(davor);) {
|
||||||
|
const g = await glied(davor);
|
||||||
|
if (!g) break;
|
||||||
|
gesehen.add(g.id);
|
||||||
|
kette.unshift({ id: g.id, name: g.name, status: g.status });
|
||||||
|
davor = g.vorgaengerId;
|
||||||
|
}
|
||||||
|
for (let danach = start.nachfolger?.id; danach && kette.length < maximal && !gesehen.has(danach);) {
|
||||||
|
const g = await glied(danach);
|
||||||
|
if (!g) break;
|
||||||
|
gesehen.add(g.id);
|
||||||
|
kette.push({ id: g.id, name: g.name, status: g.status });
|
||||||
|
danach = g.nachfolger?.id;
|
||||||
|
}
|
||||||
|
return kette;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodelle fuer Vorlagen (#019). Vorlagen enthalten keine Geheimnisse – jede angemeldete
|
||||||
|
* Rolle darf sie sehen; die Seite blendet nur die Bearbeiten-Knoepfe aus.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AUSWAHL = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
beschreibung: true,
|
||||||
|
eingebaut: true,
|
||||||
|
schluesselTyp: true,
|
||||||
|
kategorie: true,
|
||||||
|
standardLaufzeitTage: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
zielbenutzerAnlegen: true,
|
||||||
|
sudoModus: true,
|
||||||
|
sudoBefehle: true,
|
||||||
|
authorizedKeysOptionen: true,
|
||||||
|
sshConfigAlias: true,
|
||||||
|
knownHostsEintragen: true,
|
||||||
|
privatNachAusrollenLoeschen: true,
|
||||||
|
archiviertAm: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Eingebaute zuerst in Seed-Reihenfolge – sie sind der Normalfall, eigene die Ausnahme. */
|
||||||
|
export async function vorlagenListe() {
|
||||||
|
return prisma.vorlage.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
orderBy: [{ eingebaut: "desc" }, { angelegtAm: "asc" }, { name: "asc" }],
|
||||||
|
select: AUSWAHL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VorlagenListenEintrag = Awaited<ReturnType<typeof vorlagenListe>>[number];
|
||||||
|
|
||||||
|
export async function vorlageDetail(id: string) {
|
||||||
|
return prisma.vorlage.findUnique({ where: { id }, select: AUSWAHL });
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import {
|
||||||
|
offeneEntfernungen,
|
||||||
|
widerrufGesperrt,
|
||||||
|
zielkontoKandidaten,
|
||||||
|
type OffeneEntfernung,
|
||||||
|
type ZielkontoKandidat,
|
||||||
|
} from "@/server/services/widerruf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was ein Widerruf bewirkt (#024) – fuer den Bestaetigungsdialog, die Massenwiderruf-Seite und
|
||||||
|
* die Fortsetzen-Ansicht. Nur Namen, Ziele und Pfade; nie Schluesselinhalte.
|
||||||
|
*/
|
||||||
|
export type WiderrufSchluessel = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly kategorie: "PERSON" | "DIENST" | "DEPLOYMENT";
|
||||||
|
readonly status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
|
||||||
|
readonly eingeleitet: boolean;
|
||||||
|
readonly person: string | null;
|
||||||
|
readonly gesperrt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WiderrufZiel = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly schluesselId: string;
|
||||||
|
readonly ziel: string;
|
||||||
|
readonly archiviert: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WiderrufPrivatZiel = WiderrufZiel & {
|
||||||
|
readonly pfad: string;
|
||||||
|
readonly alias: string | null;
|
||||||
|
readonly beimWiderruf: "LOESCHEN" | "UMBENENNEN";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WiderrufUebersicht = {
|
||||||
|
readonly schluessel: WiderrufSchluessel[];
|
||||||
|
readonly public: WiderrufZiel[];
|
||||||
|
readonly privat: WiderrufPrivatZiel[];
|
||||||
|
readonly zielkonten: ZielkontoKandidat[];
|
||||||
|
readonly offen: OffeneEntfernung[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function widerrufUebersicht(schluesselIds: readonly string[]): Promise<WiderrufUebersicht> {
|
||||||
|
const ids = [...new Set(schluesselIds)];
|
||||||
|
const schluessel = await prisma.schluessel.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
kategorie: true,
|
||||||
|
status: true,
|
||||||
|
widerrufEingeleitetAm: true,
|
||||||
|
person: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
const [oeffentlich, privat, zielkonten, offen] = await Promise.all([
|
||||||
|
prisma.publicVerwendung.findMany({
|
||||||
|
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
schluesselId: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
host: { select: { name: true, archiviertAm: true } },
|
||||||
|
hostgruppe: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.privateVerwendung.findMany({
|
||||||
|
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
schluesselId: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
pfad: true,
|
||||||
|
sshConfig: true,
|
||||||
|
beimWiderruf: true,
|
||||||
|
host: { select: { name: true, archiviertAm: true } },
|
||||||
|
},
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
}),
|
||||||
|
zielkontoKandidaten(ids),
|
||||||
|
offeneEntfernungen(ids),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
schluessel: schluessel.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
kategorie: s.kategorie,
|
||||||
|
status: s.status,
|
||||||
|
eingeleitet: Boolean(s.widerrufEingeleitetAm),
|
||||||
|
person: s.person?.name ?? null,
|
||||||
|
gesperrt: widerrufGesperrt(s),
|
||||||
|
})),
|
||||||
|
public: oeffentlich.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
schluesselId: v.schluesselId,
|
||||||
|
ziel: v.host
|
||||||
|
? `${v.zielbenutzer}@${v.host.name}`
|
||||||
|
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
|
||||||
|
archiviert: Boolean(v.host?.archiviertAm),
|
||||||
|
})),
|
||||||
|
privat: privat.map((v) => {
|
||||||
|
const config =
|
||||||
|
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
schluesselId: v.schluesselId,
|
||||||
|
ziel: `${v.zielbenutzer}@${v.host.name}`,
|
||||||
|
pfad: v.pfad,
|
||||||
|
alias: typeof config?.alias === "string" ? config.alias : null,
|
||||||
|
beimWiderruf: v.beimWiderruf,
|
||||||
|
archiviert: Boolean(v.host.archiviertAm),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
zielkonten,
|
||||||
|
offen,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { matrixAufbauen, type MatrixDaten, type MatrixFilter } from "@/lib/zuweisungsmatrix";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodell der Zuweisungsmatrix (#025). Jede angemeldete Rolle darf es sehen – wie die
|
||||||
|
* Verwendungen auf Schluessel- und Hostseite auch.
|
||||||
|
*
|
||||||
|
* Feste `select`-Listen ohne Chiffrat-Felder (CLAUDE.md). Nur aktive Schluessel und nicht
|
||||||
|
* archivierte Hosts: widerrufene oder rotierte Keys haben keine Zukunft auf einem Host, und
|
||||||
|
* ein archivierter Host wird nicht mehr abgeglichen.
|
||||||
|
*/
|
||||||
|
export async function zuweisungsDaten(): Promise<MatrixDaten> {
|
||||||
|
const nichtEntfernt = { zustand: { not: "ENTFERNT" as const } };
|
||||||
|
const [schluessel, hosts, gruppen, oeffentlich, privat] = await Promise.all([
|
||||||
|
prisma.schluessel.findMany({
|
||||||
|
where: { status: "AKTIV" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
fingerprint: true,
|
||||||
|
kategorie: true,
|
||||||
|
person: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||||
|
}),
|
||||||
|
prisma.host.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
select: { id: true, name: true, gruppen: { select: { hostgruppeId: true } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.hostgruppe.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||||
|
prisma.publicVerwendung.findMany({
|
||||||
|
where: { ...nichtEntfernt, schluessel: { status: "AKTIV" } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
schluesselId: true,
|
||||||
|
hostId: true,
|
||||||
|
hostgruppeId: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
optionen: true,
|
||||||
|
zustand: true,
|
||||||
|
},
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.privateVerwendung.findMany({
|
||||||
|
where: { ...nichtEntfernt, schluessel: { status: "AKTIV" } },
|
||||||
|
select: { id: true, schluesselId: true, hostId: true, zielbenutzer: true, pfad: true, zustand: true },
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
schluessel,
|
||||||
|
hosts: hosts.map((h) => ({ id: h.id, name: h.name, gruppenIds: h.gruppen.map((g) => g.hostgruppeId) })),
|
||||||
|
gruppen,
|
||||||
|
oeffentlich,
|
||||||
|
privat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zuweisungsMatrix(filter: MatrixFilter = {}) {
|
||||||
|
const daten = await zuweisungsDaten();
|
||||||
|
// Personen fuer den Filter: nur die, denen ein aktiver Schluessel gehoert.
|
||||||
|
const personen = [
|
||||||
|
...new Map(
|
||||||
|
daten.schluessel.flatMap((s) => (s.person ? [[s.person.id, s.person] as const] : [])),
|
||||||
|
).values(),
|
||||||
|
].sort((a, b) => a.name.localeCompare(b.name, "de"));
|
||||||
|
return { matrix: matrixAufbauen(daten, filter), gruppen: daten.gruppen, personen };
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeitplan (#021): reiht fuer jeden eingerichteten Host einen Abgleich ein – ausser fuer Hosts,
|
||||||
|
* deren letzter Abgleich noch wartet oder laeuft.
|
||||||
|
*/
|
||||||
|
export async function alleHostsAbgleichen(angefordertVonId: string | null): Promise<number> {
|
||||||
|
const hosts = await prisma.host.findMany({
|
||||||
|
where: { archiviertAm: null, bootstrapAm: { not: null } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const laufend = new Set(
|
||||||
|
(
|
||||||
|
await prisma.vorgang.findMany({
|
||||||
|
where: { art: "ABGLEICH", status: { in: ["WARTEND", "LAEUFT"] } },
|
||||||
|
select: { hostId: true },
|
||||||
|
})
|
||||||
|
).map((v) => v.hostId),
|
||||||
|
);
|
||||||
|
let anzahl = 0;
|
||||||
|
for (const host of hosts) {
|
||||||
|
if (laufend.has(host.id)) continue;
|
||||||
|
await vorgangEinreihen({ art: "ABGLEICH", angefordertVonId, hostId: host.id });
|
||||||
|
anzahl += 1;
|
||||||
|
}
|
||||||
|
return anzahl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function abgleichAnfordern(hostId: string, angefordertVonId: string): Promise<string> {
|
||||||
|
return vorgangEinreihen({ art: "ABGLEICH", angefordertVonId, hostId });
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
|
||||||
|
import { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel";
|
||||||
|
import { eindeutigerSlug } from "@/server/services/schluessel";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import {
|
||||||
|
VerwendungsFehler,
|
||||||
|
privateVerwendungAnlegen,
|
||||||
|
publicVerwendungAnlegen,
|
||||||
|
} from "@/server/services/verwendungen";
|
||||||
|
import { widerrufEinleiten } from "@/server/services/widerruf";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gefuehrte Rotation (#023): neuer Schluessel gleichen Typs, alle aktiven Zuweisungen
|
||||||
|
* uebernehmen, ausrollen, Verbindungen testen – und erst dann den alten Schluessel ueber den
|
||||||
|
* Widerruf (#024) entfernen. Der alte endet als ROTIERT, der neue zeigt per `vorgaengerId` auf ihn.
|
||||||
|
*
|
||||||
|
* Jeder Schritt ist idempotent und liest seinen Stand aus der Datenbank: die Oberflaeche kann
|
||||||
|
* jederzeit neu laden und an derselben Stelle weitermachen.
|
||||||
|
*/
|
||||||
|
export class RotationsFehler extends VerwendungsFehler {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "RotationsFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Akteur = { readonly id: string; readonly name: string };
|
||||||
|
|
||||||
|
/** Zustaende, in denen eine Verwendung mitgenommen wird. */
|
||||||
|
const AKTIVE = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
|
||||||
|
|
||||||
|
const ALT_SELECT = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
typ: true,
|
||||||
|
kategorie: true,
|
||||||
|
status: true,
|
||||||
|
fingerprint: true,
|
||||||
|
tags: true,
|
||||||
|
ablaufAm: true,
|
||||||
|
personId: true,
|
||||||
|
vorlageId: true,
|
||||||
|
masterKeyVersion: true,
|
||||||
|
widerrufEingeleitetAm: true,
|
||||||
|
nachfolger: { select: { id: true, name: true, status: true } },
|
||||||
|
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
|
||||||
|
} satisfies Prisma.SchluesselSelect;
|
||||||
|
|
||||||
|
type Alt = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder
|
||||||
|
* ein Dienst-Key, dessen Private Key woanders liegt) braucht einen neuen Public Key von aussen.
|
||||||
|
*/
|
||||||
|
export function rotationGesperrt(
|
||||||
|
s: Pick<Alt, "kategorie" | "status" | "masterKeyVersion" | "widerrufEingeleitetAm"> & {
|
||||||
|
readonly privatZuweisungen: number;
|
||||||
|
},
|
||||||
|
): { grund: string; neuerPublicKeyNoetig?: boolean } | null {
|
||||||
|
if (s.kategorie === "DEPLOYMENT")
|
||||||
|
return { grund: "Der Deployment-Key wird über eine eigene Rotation ersetzt." };
|
||||||
|
if (s.status !== "AKTIV" && s.status !== "ABGELAUFEN")
|
||||||
|
return { grund: "Nur aktive oder abgelaufene Schlüssel lassen sich rotieren." };
|
||||||
|
if (s.kategorie === "PERSON" || (s.masterKeyVersion === null && s.privatZuweisungen === 0)) {
|
||||||
|
return {
|
||||||
|
grund:
|
||||||
|
"Den Private Key hat sshkv nicht – rotieren heißt hier: Die Person erzeugt ein neues Schlüsselpaar und liefert den neuen Public Key. Diesen importieren, genauso zuweisen und danach diesen Schlüssel widerrufen.",
|
||||||
|
neuerPublicKeyNoetig: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function altLaden(id: string): Promise<Alt> {
|
||||||
|
const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT });
|
||||||
|
if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht.");
|
||||||
|
return alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gesperrtPruefen(alt: Alt) {
|
||||||
|
// Ein laufender Widerruf gehoert zur Rotation, wenn es schon einen Nachfolger gibt.
|
||||||
|
if (alt.widerrufEingeleitetAm && !alt.nachfolger)
|
||||||
|
throw new RotationsFehler("Dieser Schlüssel wird gerade widerrufen.");
|
||||||
|
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
|
||||||
|
if (gesperrt) throw new RotationsFehler(gesperrt.grund);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt 1: Nachfolger im Worker erzeugen lassen. Liefert die Vorgangs-ID. */
|
||||||
|
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
|
||||||
|
const alt = await altLaden(altId);
|
||||||
|
gesperrtPruefen(alt);
|
||||||
|
if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger.");
|
||||||
|
const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX);
|
||||||
|
const slug = await eindeutigerSlug(null, name);
|
||||||
|
// Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen.
|
||||||
|
const ablaufAm = alt.ablaufAm && alt.ablaufAm > new Date() ? alt.ablaufAm.toISOString() : null;
|
||||||
|
const eingabe: SchluesselVorgangEingabe = {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
tags: alt.tags,
|
||||||
|
ablaufAm,
|
||||||
|
personId: alt.personId,
|
||||||
|
vorlageId: alt.vorlageId,
|
||||||
|
kommentar: null,
|
||||||
|
typ: alt.typ === "RSA4096" ? "RSA4096" : "ED25519",
|
||||||
|
vorgaengerId: alt.id,
|
||||||
|
};
|
||||||
|
return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nachfolgerVon(alt: Alt) {
|
||||||
|
if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
||||||
|
if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv.");
|
||||||
|
return alt.nachfolger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schritt 2: aktive Zuweisungen fuer den Nachfolger uebernehmen. Public: gleiche Ziele und
|
||||||
|
* Optionen. Private: gleicher Host/Benutzer und Alias, neuer Pfad – der config-Block zeigt nach
|
||||||
|
* dem Ausrollen auf die neue Datei (juengste Zuweisung gewinnt je Alias, src/worker/ablaeufe/privat.ts).
|
||||||
|
*/
|
||||||
|
export async function rotationUebernehmen(
|
||||||
|
altId: string,
|
||||||
|
akteur: Akteur,
|
||||||
|
optionen: { readonly privatErlaubt: boolean },
|
||||||
|
): Promise<{ public: number; privat: number }> {
|
||||||
|
const alt = await altLaden(altId);
|
||||||
|
gesperrtPruefen(alt);
|
||||||
|
const neu = await nachfolgerVon(alt);
|
||||||
|
const [altPublic, altPrivat, neuPublic, neuPrivat] = await Promise.all([
|
||||||
|
prisma.publicVerwendung.findMany({
|
||||||
|
where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } },
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.privateVerwendung.findMany({
|
||||||
|
where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } },
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.publicVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }),
|
||||||
|
prisma.privateVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }),
|
||||||
|
]);
|
||||||
|
if (altPrivat.length > 0 && !optionen.privatErlaubt)
|
||||||
|
throw new RotationsFehler("Private Keys rollt nur ein Admin aus.");
|
||||||
|
|
||||||
|
let anzahlPublic = 0;
|
||||||
|
for (const v of altPublic) {
|
||||||
|
const schon = neuPublic.some(
|
||||||
|
(n) => n.hostId === v.hostId && n.hostgruppeId === v.hostgruppeId && n.zielbenutzer === v.zielbenutzer,
|
||||||
|
);
|
||||||
|
if (schon) continue;
|
||||||
|
await publicVerwendungAnlegen(
|
||||||
|
{
|
||||||
|
schluesselId: neu.id,
|
||||||
|
hostId: v.hostId,
|
||||||
|
hostgruppeId: v.hostgruppeId,
|
||||||
|
zielbenutzer: v.zielbenutzer,
|
||||||
|
optionen: v.optionen,
|
||||||
|
},
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
anzahlPublic += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const datum = rotationsDatum().kompakt;
|
||||||
|
let anzahlPrivat = 0;
|
||||||
|
for (const v of altPrivat) {
|
||||||
|
const config = v.sshConfig as { alias: string; hostName: string; benutzer: string; port: number } | null;
|
||||||
|
const schon = neuPrivat.some(
|
||||||
|
(n) =>
|
||||||
|
n.hostId === v.hostId &&
|
||||||
|
n.zielbenutzer === v.zielbenutzer &&
|
||||||
|
((n.sshConfig as { alias?: string } | null)?.alias ?? null) === (config?.alias ?? null),
|
||||||
|
);
|
||||||
|
if (schon) continue;
|
||||||
|
// Auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index ueber alle Zustaende).
|
||||||
|
const belegt = new Set(
|
||||||
|
(
|
||||||
|
await prisma.privateVerwendung.findMany({
|
||||||
|
where: { hostId: v.hostId, zielbenutzer: v.zielbenutzer },
|
||||||
|
select: { pfad: true },
|
||||||
|
})
|
||||||
|
).map((p) => p.pfad),
|
||||||
|
);
|
||||||
|
await privateVerwendungAnlegen(
|
||||||
|
{
|
||||||
|
schluesselId: neu.id,
|
||||||
|
hostId: v.hostId,
|
||||||
|
zielbenutzer: v.zielbenutzer,
|
||||||
|
pfad: rotationsPfad(v.pfad, datum, belegt),
|
||||||
|
sshConfig: config,
|
||||||
|
knownHostsFuer: v.knownHostsFuer,
|
||||||
|
beimWiderruf: v.beimWiderruf,
|
||||||
|
},
|
||||||
|
akteur,
|
||||||
|
);
|
||||||
|
anzahlPrivat += 1;
|
||||||
|
}
|
||||||
|
if (anzahlPublic + anzahlPrivat > 0) {
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "schluessel.rotation.uebernommen",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: alt.fingerprint,
|
||||||
|
details: { nachfolgerId: neu.id, public: anzahlPublic, privat: anzahlPrivat },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { public: anzahlPublic, privat: anzahlPrivat };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verbindungstests, die der Nachfolger bestehen muss: jede Private-Zuweisung mit Alias. */
|
||||||
|
async function testZiele(neuId: string) {
|
||||||
|
const verwendungen = await prisma.privateVerwendung.findMany({
|
||||||
|
where: { schluesselId: neuId, zustand: { not: "ENTFERNT" } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
sshConfig: true,
|
||||||
|
zustand: true,
|
||||||
|
zuletztAngewendetAm: true,
|
||||||
|
host: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
});
|
||||||
|
const mitAlias = verwendungen.filter((v) => (v.sshConfig as { alias?: string } | null)?.alias);
|
||||||
|
return Promise.all(
|
||||||
|
mitAlias.map(async (v) => {
|
||||||
|
// Gruen ist nur ein Test nach dem letzten Ausrollen – ein aelterer bewiese nichts.
|
||||||
|
const test = await prisma.vorgang.findFirst({
|
||||||
|
where: {
|
||||||
|
art: "VERBINDUNGSTEST",
|
||||||
|
eingabe: { equals: { privatVerwendungId: v.id } },
|
||||||
|
...(v.zuletztAngewendetAm ? { angelegtAm: { gte: v.zuletztAngewendetAm } } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { angelegtAm: "desc" },
|
||||||
|
select: { status: true, fehler: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
ziel: `${v.zielbenutzer}@${v.host.name} → ${(v.sshConfig as { alias: string }).alias}`,
|
||||||
|
ausgerollt: v.zustand === "AUSGEROLLT",
|
||||||
|
status: test?.status ?? null,
|
||||||
|
fehler: test?.fehler ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RotationsStand = Awaited<ReturnType<typeof rotationsStand>>;
|
||||||
|
|
||||||
|
/** Wo steht die Rotation? Fuer die Schrittanzeige – alles aus der Datenbank. */
|
||||||
|
export async function rotationsStand(altId: string) {
|
||||||
|
const alt = await altLaden(altId);
|
||||||
|
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
|
||||||
|
const neu = alt.nachfolger;
|
||||||
|
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const };
|
||||||
|
|
||||||
|
const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([
|
||||||
|
prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
|
||||||
|
prisma.privateVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
|
||||||
|
prisma.publicVerwendung.findMany({
|
||||||
|
where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } },
|
||||||
|
select: { zustand: true },
|
||||||
|
}),
|
||||||
|
prisma.privateVerwendung.findMany({
|
||||||
|
where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } },
|
||||||
|
select: { zustand: true },
|
||||||
|
}),
|
||||||
|
testZiele(neu.id),
|
||||||
|
]);
|
||||||
|
const neuOffen = [...neuPublic, ...neuPrivat].filter((v) => v.zustand !== "AUSGEROLLT").length;
|
||||||
|
// Uebernommen, sobald der Neue mindestens so viele Zuweisungen hat wie der Alte (noch) aktive.
|
||||||
|
const uebernommen =
|
||||||
|
alt.widerrufEingeleitetAm !== null || (neuPublic.length >= altPublic && neuPrivat.length >= altPrivat);
|
||||||
|
const testsGruen = tests.every((t) => t.ausgerollt && t.status === "ERLEDIGT");
|
||||||
|
const schritt =
|
||||||
|
alt.status === "ROTIERT"
|
||||||
|
? (5 as const)
|
||||||
|
: !uebernommen
|
||||||
|
? (1 as const)
|
||||||
|
: alt.widerrufEingeleitetAm
|
||||||
|
? (4 as const)
|
||||||
|
: neuOffen > 0
|
||||||
|
? (2 as const)
|
||||||
|
: !testsGruen
|
||||||
|
? (3 as const)
|
||||||
|
: (4 as const);
|
||||||
|
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schritt 5: den alten Schluessel widerrufen – nur wenn der Nachfolger ueberall ausgerollt ist
|
||||||
|
* und jeder Verbindungstest seit dem letzten Ausrollen gruen war.
|
||||||
|
*/
|
||||||
|
export async function rotationAltWiderrufen(
|
||||||
|
altId: string,
|
||||||
|
akteur: Akteur,
|
||||||
|
optionen: { readonly privatErlaubt: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const stand = await rotationsStand(altId);
|
||||||
|
if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
||||||
|
if (stand.schritt === 5) return;
|
||||||
|
if (stand.schritt < 4) {
|
||||||
|
throw new RotationsFehler(
|
||||||
|
stand.schritt === 3
|
||||||
|
? "Erst wenn alle Verbindungstests mit dem neuen Schlüssel grün sind, wird der alte entfernt."
|
||||||
|
: "Der neue Schlüssel ist noch nicht überall ausgerollt.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await widerrufEinleiten([altId], akteur, optionen);
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ export type Soll = {
|
|||||||
readonly eintraege: SollEintrag[];
|
readonly eintraege: SollEintrag[];
|
||||||
/** Fingerprints, die nie aus dem Block fallen duerfen (Deployment-Key). */
|
/** Fingerprints, die nie aus dem Block fallen duerfen (Deployment-Key). */
|
||||||
readonly pflicht: string[];
|
readonly pflicht: string[];
|
||||||
|
/** Uebernommene Keys (#022), deren Zeile ausserhalb des Blocks verschwinden soll. */
|
||||||
|
readonly ausserhalbEntfernen: string[];
|
||||||
/** Verwendungen, die in diesem Soll stecken oder entfernt werden – fuer den Zustand danach. */
|
/** Verwendungen, die in diesem Soll stecken oder entfernt werden – fuer den Zustand danach. */
|
||||||
readonly verwendungIds: { readonly drin: string[]; readonly raus: string[] };
|
readonly verwendungIds: { readonly drin: string[]; readonly raus: string[] };
|
||||||
};
|
};
|
||||||
@@ -58,6 +60,7 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
optionen: true,
|
optionen: true,
|
||||||
|
ausserhalbEntfernen: true,
|
||||||
zustand: true,
|
zustand: true,
|
||||||
angelegtAm: true,
|
angelegtAm: true,
|
||||||
schluessel: { select: { publicKey: true, fingerprint: true, status: true } },
|
schluessel: { select: { publicKey: true, fingerprint: true, status: true } },
|
||||||
@@ -78,6 +81,7 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
|||||||
|
|
||||||
const drin: string[] = [];
|
const drin: string[] = [];
|
||||||
const raus: string[] = [];
|
const raus: string[] = [];
|
||||||
|
const ausserhalbEntfernen: string[] = [];
|
||||||
for (const verwendung of verwendungen) {
|
for (const verwendung of verwendungen) {
|
||||||
const aktiv = verwendung.schluessel.status === "AKTIV" || verwendung.schluessel.status === "ROTIERT";
|
const aktiv = verwendung.schluessel.status === "AKTIV" || verwendung.schluessel.status === "ROTIERT";
|
||||||
if ((IM_SOLL as readonly string[]).includes(verwendung.zustand) && aktiv) {
|
if ((IM_SOLL as readonly string[]).includes(verwendung.zustand) && aktiv) {
|
||||||
@@ -87,11 +91,12 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
|||||||
optionen: verwendung.optionen,
|
optionen: verwendung.optionen,
|
||||||
});
|
});
|
||||||
drin.push(verwendung.id);
|
drin.push(verwendung.id);
|
||||||
|
if (verwendung.ausserhalbEntfernen) ausserhalbEntfernen.push(verwendung.schluessel.fingerprint);
|
||||||
} else {
|
} else {
|
||||||
raus.push(verwendung.id);
|
raus.push(verwendung.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { eintraege, pflicht, verwendungIds: { drin, raus } };
|
return { eintraege, pflicht, ausserhalbEntfernen, verwendungIds: { drin, raus } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Ziel des Ausrollens: authorized_keys (public) oder ~/.ssh-Dateien eines Quell-Hosts (privat). */
|
/** Ein Ziel des Ausrollens: authorized_keys (public) oder ~/.ssh-Dateien eines Quell-Hosts (privat). */
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { IstEintrag } from "@/lib/ssh/abgleich";
|
||||||
|
import { authorizedKeysLesen, type SchluesselZeile } from "@/lib/ssh/authorized-keys";
|
||||||
|
import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { publicKeyAnlegen } from "@/server/services/schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorhandene Keys in die Verwaltung uebernehmen (#022). Grundlage ist die letzte Beobachtung
|
||||||
|
* des Abgleichs – uebernommen wird nur, was sshkv dort wirklich gesehen hat.
|
||||||
|
*
|
||||||
|
* Zwei Wege:
|
||||||
|
* - „bekannt“: der Key wird als Schluessel aufgenommen (mit Person/Zweck). Auf dem Host
|
||||||
|
* aendert sich nichts, der Befund „unbekannter Schluessel“ verschwindet beim naechsten Abgleich.
|
||||||
|
* - „verwalten“: zusaetzlich eine Zuweisung, die beim naechsten Ausrollen die Zeile in den
|
||||||
|
* verwalteten Block holt und die alte Zeile ausserhalb entfernt (mit Dry-Run).
|
||||||
|
*/
|
||||||
|
export type Uebernahme = {
|
||||||
|
readonly hostId: string;
|
||||||
|
readonly zielbenutzer: string;
|
||||||
|
readonly fingerprint: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly kategorie: "PERSON" | "DIENST";
|
||||||
|
readonly personId: string | null;
|
||||||
|
readonly verwalten: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Akteur = { readonly id: string; readonly name: string };
|
||||||
|
|
||||||
|
export async function keyUebernehmen(eingabe: Uebernahme, akteur: Akteur): Promise<{ schluesselId: string }> {
|
||||||
|
const beobachtung = await prisma.beobachtung.findUnique({
|
||||||
|
where: {
|
||||||
|
hostId_zielbenutzer_datei: {
|
||||||
|
hostId: eingabe.hostId,
|
||||||
|
zielbenutzer: eingabe.zielbenutzer,
|
||||||
|
datei: ".ssh/authorized_keys",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { host: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
const eintrag = ((beobachtung?.eintraege ?? []) as unknown as IstEintrag[]).find(
|
||||||
|
(e) => e.fingerprint === eingabe.fingerprint,
|
||||||
|
);
|
||||||
|
if (!beobachtung || !eintrag) {
|
||||||
|
throw new FachFehler(
|
||||||
|
"Diesen Schlüssel hat der letzte Abgleich dort nicht gesehen. Bitte erneut abgleichen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const zeile = authorizedKeysLesen(eintrag.zeile).zeilen.find(
|
||||||
|
(z): z is SchluesselZeile => z.art === "schluessel",
|
||||||
|
);
|
||||||
|
if (!zeile) throw new FachFehler("Die Zeile ließ sich nicht lesen.");
|
||||||
|
|
||||||
|
let optionen: string | null = null;
|
||||||
|
if (eingabe.verwalten && zeile.optionen) {
|
||||||
|
try {
|
||||||
|
optionen = optionenPruefen(zeile.optionen) || null;
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof OptionenFehler) {
|
||||||
|
throw new FachFehler(`Die Optionen der Zeile lassen sich nicht übernehmen: ${fehler.message}`);
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const neu = await publicKeyAnlegen(
|
||||||
|
{
|
||||||
|
name: eingabe.name,
|
||||||
|
slug: null,
|
||||||
|
kategorie: eingabe.kategorie,
|
||||||
|
publicKey: `${zeile.typ} ${zeile.base64}${zeile.kommentar ? ` ${zeile.kommentar}` : ""}`,
|
||||||
|
kommentar: null,
|
||||||
|
tags: ["übernommen"],
|
||||||
|
ablaufAm: null,
|
||||||
|
personId: eingabe.personId,
|
||||||
|
vorlageId: null,
|
||||||
|
},
|
||||||
|
akteur,
|
||||||
|
"schluessel.uebernommen",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (eingabe.verwalten) {
|
||||||
|
await prisma.publicVerwendung.create({
|
||||||
|
data: {
|
||||||
|
schluesselId: neu.id,
|
||||||
|
hostId: eingabe.hostId,
|
||||||
|
zielbenutzer: eingabe.zielbenutzer,
|
||||||
|
optionen,
|
||||||
|
ausserhalbEntfernen: true,
|
||||||
|
angelegtVonId: akteur.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: eingabe.verwalten ? "uebernahme.verwalten" : "uebernahme.bekannt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: beobachtung.host.name,
|
||||||
|
zielbenutzer: eingabe.zielbenutzer,
|
||||||
|
fingerprint: neu.fingerprint,
|
||||||
|
details: { optionen },
|
||||||
|
});
|
||||||
|
return { schluesselId: neu.id };
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ export class VerwendungsFehler extends Error {
|
|||||||
|
|
||||||
type Akteur = { readonly id: string; readonly name: string };
|
type Akteur = { readonly id: string; readonly name: string };
|
||||||
|
|
||||||
|
/** Ein Schluessel im Widerruf (#024) bekommt keine neuen Ziele – sonst wuerde er nie fertig. */
|
||||||
|
const WIDERRUF_LAEUFT = "Dieser Schlüssel wird gerade widerrufen und lässt sich nicht mehr zuweisen.";
|
||||||
|
|
||||||
export type PublicZuweisung = {
|
export type PublicZuweisung = {
|
||||||
readonly schluesselId: string;
|
readonly schluesselId: string;
|
||||||
readonly hostId?: string | null;
|
readonly hostId?: string | null;
|
||||||
@@ -46,11 +49,12 @@ export async function publicVerwendungAnlegen(eingabe: PublicZuweisung, akteur:
|
|||||||
}
|
}
|
||||||
const schluessel = await prisma.schluessel.findUnique({
|
const schluessel = await prisma.schluessel.findUnique({
|
||||||
where: { id: eingabe.schluesselId },
|
where: { id: eingabe.schluesselId },
|
||||||
select: { status: true, fingerprint: true, kategorie: true },
|
select: { status: true, fingerprint: true, kategorie: true, widerrufEingeleitetAm: true },
|
||||||
});
|
});
|
||||||
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
|
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
|
||||||
if (schluessel.status !== "AKTIV")
|
if (schluessel.status !== "AKTIV")
|
||||||
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich zuweisen.");
|
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich zuweisen.");
|
||||||
|
if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
|
||||||
if (schluessel.kategorie === "DEPLOYMENT") {
|
if (schluessel.kategorie === "DEPLOYMENT") {
|
||||||
throw new VerwendungsFehler("Der Deployment-Key wird von sshkv selbst verwaltet.");
|
throw new VerwendungsFehler("Der Deployment-Key wird von sshkv selbst verwaltet.");
|
||||||
}
|
}
|
||||||
@@ -116,13 +120,47 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ziele mehrerer Schluessel ohne Doppelte – ein gemeinsamer Dry-Run beim Massenwiderruf (#024). */
|
||||||
|
async function zieleMehrererSchluessel(
|
||||||
|
schluesselIds: readonly string[],
|
||||||
|
mitPrivat: boolean,
|
||||||
|
): Promise<Ziel[]> {
|
||||||
|
const ziele = new Map<string, Ziel>();
|
||||||
|
for (const schluesselId of schluesselIds) {
|
||||||
|
const liste = [
|
||||||
|
...(await zieleVonVerwendungen({ schluesselId })),
|
||||||
|
...(mitPrivat ? await zieleVonPrivatVerwendungen({ schluesselId }) : []),
|
||||||
|
];
|
||||||
|
for (const ziel of liste)
|
||||||
|
ziele.set(`${ziel.hostId}\u0000${ziel.zielbenutzer}\u0000${ziel.art ?? "public"}`, ziel);
|
||||||
|
}
|
||||||
|
return [...ziele.values()];
|
||||||
|
}
|
||||||
|
|
||||||
/** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */
|
/** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */
|
||||||
export async function trockenlaufStarten(
|
export async function trockenlaufStarten(
|
||||||
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
auswahl: {
|
||||||
|
schluesselId?: string;
|
||||||
|
/** Mehrere Schluessel in einem Dry-Run (Massenwiderruf, #024). */
|
||||||
|
schluesselIds?: readonly string[];
|
||||||
|
hostId?: string;
|
||||||
|
verwendungIds?: string[];
|
||||||
|
ziele?: Ziel[];
|
||||||
|
},
|
||||||
akteur: Akteur,
|
akteur: Akteur,
|
||||||
/** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */
|
/** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */
|
||||||
mitPrivat = false,
|
mitPrivat = false,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
if (auswahl.schluesselIds && !auswahl.ziele) {
|
||||||
|
return trockenlaufStarten(
|
||||||
|
{
|
||||||
|
ziele: await zieleMehrererSchluessel(auswahl.schluesselIds, mitPrivat),
|
||||||
|
...(auswahl.schluesselIds.length === 1 ? { schluesselId: auswahl.schluesselIds[0] } : {}),
|
||||||
|
},
|
||||||
|
akteur,
|
||||||
|
mitPrivat,
|
||||||
|
);
|
||||||
|
}
|
||||||
const ziele = auswahl.ziele ?? [
|
const ziele = auswahl.ziele ?? [
|
||||||
...(await zieleVonVerwendungen({
|
...(await zieleVonVerwendungen({
|
||||||
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||||
@@ -197,11 +235,13 @@ export async function privateVerwendungAnlegen(eingabe: PrivateZuweisung, akteur
|
|||||||
kategorie: true,
|
kategorie: true,
|
||||||
privatGeloeschtAm: true,
|
privatGeloeschtAm: true,
|
||||||
masterKeyVersion: true,
|
masterKeyVersion: true,
|
||||||
|
widerrufEingeleitetAm: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
|
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
|
||||||
if (schluessel.status !== "AKTIV")
|
if (schluessel.status !== "AKTIV")
|
||||||
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich ausrollen.");
|
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich ausrollen.");
|
||||||
|
if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
|
||||||
if (schluessel.kategorie !== "DIENST")
|
if (schluessel.kategorie !== "DIENST")
|
||||||
throw new VerwendungsFehler("Private Keys werden nur für Service-Schlüssel ausgerollt.");
|
throw new VerwendungsFehler("Private Keys werden nur für Service-Schlüssel ausgerollt.");
|
||||||
// masterKeyVersion ist genau dann gesetzt, wenn ein Private Key gespeichert ist (DB-CHECK).
|
// masterKeyVersion ist genau dann gesetzt, wenn ein Private Key gespeichert ist (DB-CHECK).
|
||||||
|
|||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widerruf und Massenwiderruf (#024).
|
||||||
|
*
|
||||||
|
* Ablauf in drei Schritten, damit der Status nie mehr verspricht, als auf den Hosts passiert ist:
|
||||||
|
*
|
||||||
|
* 1. `widerrufEinleiten`: alle Verwendungen auf ENTFERNEN, `widerrufEingeleitetAm` setzen.
|
||||||
|
* Der Schluessel bleibt AKTIV, laesst sich aber nicht mehr zuweisen.
|
||||||
|
* 2. Dry-Run und Ausrollen wie sonst auch (#016/#017). Was gelingt, wird ENTFERNT; was auf einem
|
||||||
|
* Host scheitert (nicht erreichbar, Datei geaendert), bleibt ENTFERNEN.
|
||||||
|
* 3. `widerrufAbschliessen`: erst wenn keine Verwendung mehr offen ist, wird der Status
|
||||||
|
* WIDERRUFEN (bzw. ROTIERT, wenn ein Nachfolger aus einer Rotation existiert, #023).
|
||||||
|
*
|
||||||
|
* Optional dazwischen: Zielkonten entfernen, die fuer diese Schluessel angelegt wurden.
|
||||||
|
*/
|
||||||
|
export class WiderrufFehler extends VerwendungsFehler {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "WiderrufFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Akteur = { readonly id: string; readonly name: string };
|
||||||
|
|
||||||
|
/** Verwendungen in diesen Zustaenden zaehlen als „noch in Gebrauch“ (fuer Zielkonten). */
|
||||||
|
const IN_GEBRAUCH = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
|
||||||
|
|
||||||
|
const SCHLUESSEL_KOPF = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
kategorie: true,
|
||||||
|
status: true,
|
||||||
|
fingerprint: true,
|
||||||
|
widerrufEingeleitetAm: true,
|
||||||
|
nachfolger: { select: { id: true, status: true } },
|
||||||
|
} satisfies Prisma.SchluesselSelect;
|
||||||
|
|
||||||
|
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
|
||||||
|
|
||||||
|
/** Warum ein Schluessel nicht widerrufen werden kann – oder null. */
|
||||||
|
export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | null {
|
||||||
|
// Der Deployment-Key ist sshkvs eigener Zugang; ohne ihn ist kein Host mehr erreichbar.
|
||||||
|
if (s.kategorie === "DEPLOYMENT")
|
||||||
|
return "Der Deployment-Key lässt sich nicht widerrufen – er wird über eine eigene Rotation ersetzt.";
|
||||||
|
if (s.status === "WIDERRUFEN") return `„${s.name}“ ist bereits widerrufen.`;
|
||||||
|
if (s.status === "ROTIERT") return `„${s.name}“ ist bereits rotiert und von allen Hosts entfernt.`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schluesselLaden(ids: readonly string[]): Promise<SchluesselKopf[]> {
|
||||||
|
const eindeutig = [...new Set(ids)];
|
||||||
|
if (eindeutig.length === 0) throw new WiderrufFehler("Bitte mindestens einen Schlüssel wählen.");
|
||||||
|
const liste = await prisma.schluessel.findMany({
|
||||||
|
where: { id: { in: eindeutig } },
|
||||||
|
select: SCHLUESSEL_KOPF,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht.");
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Zielkonten
|
||||||
|
|
||||||
|
export type ZielkontoKandidat = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly hostId: string;
|
||||||
|
readonly hostName: string;
|
||||||
|
readonly benutzername: string;
|
||||||
|
readonly sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||||
|
/** Namen anderer Schluessel, die das Konto noch nutzen – dann bleibt es. */
|
||||||
|
readonly nochGenutztVon: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zielkonten, die sshkv fuer diese Schluessel angelegt hat: ein von sshkv angelegtes, noch
|
||||||
|
* vorhandenes Konto auf einem Host, auf dem einer der Schluessel (in irgendeinem Zustand) fuer
|
||||||
|
* genau diesen Benutzer zugewiesen war. Ein Zielkonto kennt seinen Schluessel nicht; die
|
||||||
|
* Zuweisung ist die einzige belastbare Verbindung.
|
||||||
|
*/
|
||||||
|
export async function zielkontoKandidaten(schluesselIds: readonly string[]): Promise<ZielkontoKandidat[]> {
|
||||||
|
const ids = [...new Set(schluesselIds)];
|
||||||
|
const [oeffentlich, privat] = await Promise.all([
|
||||||
|
prisma.publicVerwendung.findMany({
|
||||||
|
where: { schluesselId: { in: ids } },
|
||||||
|
select: {
|
||||||
|
zielbenutzer: true,
|
||||||
|
hostId: true,
|
||||||
|
hostgruppe: { select: { mitglieder: { select: { hostId: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.privateVerwendung.findMany({
|
||||||
|
where: { schluesselId: { in: ids } },
|
||||||
|
select: { zielbenutzer: true, hostId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const paare = new Set<string>();
|
||||||
|
for (const v of oeffentlich) {
|
||||||
|
const hosts = v.hostId ? [v.hostId] : (v.hostgruppe?.mitglieder.map((m) => m.hostId) ?? []);
|
||||||
|
for (const hostId of hosts) paare.add(`${hostId}\u0000${v.zielbenutzer}`);
|
||||||
|
}
|
||||||
|
for (const v of privat) paare.add(`${v.hostId}\u0000${v.zielbenutzer}`);
|
||||||
|
if (paare.size === 0) return [];
|
||||||
|
|
||||||
|
const konten = await prisma.zielkonto.findMany({
|
||||||
|
where: {
|
||||||
|
entferntAm: null,
|
||||||
|
vonSshkvAngelegt: true,
|
||||||
|
host: { archiviertAm: null },
|
||||||
|
OR: [...paare].map((p) => {
|
||||||
|
const [hostId, benutzername] = p.split("\u0000") as [string, string];
|
||||||
|
return { hostId, benutzername };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
hostId: true,
|
||||||
|
benutzername: true,
|
||||||
|
sudoModus: true,
|
||||||
|
host: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ host: { name: "asc" } }, { benutzername: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const ergebnis: ZielkontoKandidat[] = [];
|
||||||
|
for (const konto of konten) {
|
||||||
|
const [andere, anderePrivat] = await Promise.all([
|
||||||
|
prisma.publicVerwendung.findMany({
|
||||||
|
where: {
|
||||||
|
schluesselId: { notIn: ids },
|
||||||
|
zielbenutzer: konto.benutzername,
|
||||||
|
zustand: { in: [...IN_GEBRAUCH] },
|
||||||
|
schluessel: { widerrufEingeleitetAm: null },
|
||||||
|
OR: [{ hostId: konto.hostId }, { hostgruppe: { mitglieder: { some: { hostId: konto.hostId } } } }],
|
||||||
|
},
|
||||||
|
select: { schluessel: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
prisma.privateVerwendung.findMany({
|
||||||
|
where: {
|
||||||
|
schluesselId: { notIn: ids },
|
||||||
|
hostId: konto.hostId,
|
||||||
|
zielbenutzer: konto.benutzername,
|
||||||
|
zustand: { in: [...IN_GEBRAUCH] },
|
||||||
|
schluessel: { widerrufEingeleitetAm: null },
|
||||||
|
},
|
||||||
|
select: { schluessel: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
ergebnis.push({
|
||||||
|
id: konto.id,
|
||||||
|
hostId: konto.hostId,
|
||||||
|
hostName: konto.host.name,
|
||||||
|
benutzername: konto.benutzername,
|
||||||
|
sudoModus: konto.sudoModus,
|
||||||
|
nochGenutztVon: [...new Set([...andere, ...anderePrivat].map((v) => v.schluessel.name))].sort(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ergebnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Einleiten
|
||||||
|
|
||||||
|
export type EinleitenOptionen = {
|
||||||
|
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
|
||||||
|
readonly privatErlaubt: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schritt 1: Verwendungen zum Entfernen vormerken. Idempotent – ein zweiter Aufruf (etwa beim
|
||||||
|
* Fortsetzen) aendert an bereits vorgemerkten Verwendungen nichts.
|
||||||
|
*/
|
||||||
|
export async function widerrufEinleiten(
|
||||||
|
schluesselIds: readonly string[],
|
||||||
|
akteur: Akteur,
|
||||||
|
optionen: EinleitenOptionen,
|
||||||
|
): Promise<void> {
|
||||||
|
const liste = await schluesselLaden(schluesselIds);
|
||||||
|
for (const s of liste) {
|
||||||
|
const grund = widerrufGesperrt(s);
|
||||||
|
if (grund) throw new WiderrufFehler(grund);
|
||||||
|
}
|
||||||
|
const ids = liste.map((s) => s.id);
|
||||||
|
const privatOffen = await prisma.privateVerwendung.count({
|
||||||
|
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||||
|
});
|
||||||
|
if (privatOffen > 0 && !optionen.privatErlaubt) {
|
||||||
|
throw new WiderrufFehler(
|
||||||
|
"Private Keys auf Hosts entfernt nur ein Admin. Bitte einen Admin widerrufen lassen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const jetzt = new Date();
|
||||||
|
for (const s of liste) {
|
||||||
|
const [oeffentlich, privat] = await Promise.all([
|
||||||
|
tx.publicVerwendung.findMany({
|
||||||
|
where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zustand: true,
|
||||||
|
zuletztAngewendetAm: true,
|
||||||
|
host: { select: { name: true, archiviertAm: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tx.privateVerwendung.findMany({
|
||||||
|
where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zustand: true,
|
||||||
|
zuletztAngewendetAm: true,
|
||||||
|
host: { select: { name: true, archiviertAm: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
// Nie ausgerollt: auf dem Host steht nichts. Archivierter Host: sshkv verbindet sich dort
|
||||||
|
// nicht mehr – das Protokoll haelt fest, dass dort nichts entfernt wurde.
|
||||||
|
const sofort = (v: (typeof oeffentlich)[number]) =>
|
||||||
|
(v.zustand === "GEPLANT" && !v.zuletztAngewendetAm) || Boolean(v.host?.archiviertAm);
|
||||||
|
const archiviert = [...oeffentlich, ...privat]
|
||||||
|
.filter((v) => v.host?.archiviertAm)
|
||||||
|
.map((v) => v.host!.name);
|
||||||
|
await tx.publicVerwendung.updateMany({
|
||||||
|
where: { id: { in: oeffentlich.filter(sofort).map((v) => v.id) } },
|
||||||
|
data: { zustand: "ENTFERNT" },
|
||||||
|
});
|
||||||
|
await tx.publicVerwendung.updateMany({
|
||||||
|
where: { id: { in: oeffentlich.filter((v) => !sofort(v)).map((v) => v.id) } },
|
||||||
|
data: { zustand: "ENTFERNEN" },
|
||||||
|
});
|
||||||
|
await tx.privateVerwendung.updateMany({
|
||||||
|
where: { id: { in: privat.filter(sofort).map((v) => v.id) } },
|
||||||
|
data: { zustand: "ENTFERNT" },
|
||||||
|
});
|
||||||
|
await tx.privateVerwendung.updateMany({
|
||||||
|
where: { id: { in: privat.filter((v) => !sofort(v)).map((v) => v.id) } },
|
||||||
|
data: { zustand: "ENTFERNEN" },
|
||||||
|
});
|
||||||
|
if (!s.widerrufEingeleitetAm) {
|
||||||
|
await tx.schluessel.update({
|
||||||
|
where: { id: s.id },
|
||||||
|
data: { widerrufEingeleitetAm: jetzt },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "schluessel.widerruf.eingeleitet",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: s.fingerprint,
|
||||||
|
details: {
|
||||||
|
name: s.name,
|
||||||
|
public: oeffentlich.length,
|
||||||
|
privat: privat.length,
|
||||||
|
ohneHostZugriff: oeffentlich.filter(sofort).length + privat.filter(sofort).length,
|
||||||
|
archivierteHosts: [...new Set(archiviert)].sort(),
|
||||||
|
massenwiderruf: liste.length > 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Offene Entfernungen
|
||||||
|
|
||||||
|
export type OffeneEntfernung = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly schluesselId: string;
|
||||||
|
readonly art: "public" | "privat";
|
||||||
|
readonly ziel: string;
|
||||||
|
readonly zustand: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Was fuer diese Schluessel noch nicht entfernt ist – fuer Seite und Abschluss. */
|
||||||
|
export async function offeneEntfernungen(schluesselIds: readonly string[]): Promise<OffeneEntfernung[]> {
|
||||||
|
const ids = [...new Set(schluesselIds)];
|
||||||
|
const [oeffentlich, privat] = await Promise.all([
|
||||||
|
prisma.publicVerwendung.findMany({
|
||||||
|
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
schluesselId: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
zustand: true,
|
||||||
|
host: { select: { name: true } },
|
||||||
|
hostgruppe: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.privateVerwendung.findMany({
|
||||||
|
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
schluesselId: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
pfad: true,
|
||||||
|
zustand: true,
|
||||||
|
host: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return [
|
||||||
|
...oeffentlich.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
schluesselId: v.schluesselId,
|
||||||
|
art: "public" as const,
|
||||||
|
ziel: v.host
|
||||||
|
? `${v.zielbenutzer}@${v.host.name}:~/.ssh/authorized_keys`
|
||||||
|
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe):~/.ssh/authorized_keys`,
|
||||||
|
zustand: v.zustand,
|
||||||
|
})),
|
||||||
|
...privat.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
schluesselId: v.schluesselId,
|
||||||
|
art: "privat" as const,
|
||||||
|
ziel: `${v.zielbenutzer}@${v.host.name}:${v.pfad}`,
|
||||||
|
zustand: v.zustand,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Zielkonten entfernen
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionaler Schritt nach dem Ausrollen: ausgewaehlte Zielkonten entfernen. Erst wenn die
|
||||||
|
* Schluessel von den Hosts verschwunden sind – sonst haette ein Fehlschlag beim Ausrollen einen
|
||||||
|
* halb geloeschten Benutzer mit noch gueltigem Schluessel zur Folge.
|
||||||
|
*/
|
||||||
|
export async function widerrufZielkontenEntfernen(
|
||||||
|
schluesselIds: readonly string[],
|
||||||
|
zielkontoIds: readonly string[],
|
||||||
|
akteur: Akteur,
|
||||||
|
): Promise<{ id: string; beschriftung: string }[]> {
|
||||||
|
const liste = await schluesselLaden(schluesselIds);
|
||||||
|
if (liste.some((s) => !s.widerrufEingeleitetAm))
|
||||||
|
throw new WiderrufFehler("Bitte zuerst den Widerruf einleiten.");
|
||||||
|
const offen = await offeneEntfernungen(liste.map((s) => s.id));
|
||||||
|
if (offen.length > 0) {
|
||||||
|
throw new WiderrufFehler(
|
||||||
|
"Zielkonten werden erst entfernt, wenn die Schlüssel von allen Hosts entfernt sind.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const kandidaten = new Map((await zielkontoKandidaten(liste.map((s) => s.id))).map((k) => [k.id, k]));
|
||||||
|
const vorgaenge: { id: string; beschriftung: string }[] = [];
|
||||||
|
const gewaehlt = [...new Set(zielkontoIds)];
|
||||||
|
for (const id of gewaehlt) {
|
||||||
|
const konto = kandidaten.get(id);
|
||||||
|
if (!konto) throw new WiderrufFehler("Dieses Zielkonto gehört nicht zu den gewählten Schlüsseln.");
|
||||||
|
if (konto.nochGenutztVon.length > 0) {
|
||||||
|
throw new WiderrufFehler(
|
||||||
|
`${konto.benutzername}@${konto.hostName} wird noch von „${konto.nochGenutztVon.join("“, „")}“ genutzt und bleibt.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of gewaehlt) {
|
||||||
|
const konto = kandidaten.get(id)!;
|
||||||
|
vorgaenge.push({
|
||||||
|
id: await vorgangEinreihen({
|
||||||
|
art: "ZIELKONTO_ENTFERNEN",
|
||||||
|
angefordertVonId: akteur.id,
|
||||||
|
hostId: konto.hostId,
|
||||||
|
eingabe: { benutzer: konto.benutzername },
|
||||||
|
}),
|
||||||
|
beschriftung: `${konto.benutzername}@${konto.hostName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return vorgaenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Abschliessen
|
||||||
|
|
||||||
|
export type AbschlussErgebnis = {
|
||||||
|
readonly schluesselId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly status: "WIDERRUFEN" | "ROTIERT" | "OFFEN";
|
||||||
|
readonly offen: OffeneEntfernung[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schritt 3: Status setzen – aber nur fuer Schluessel ohne offene Entfernung. Die anderen
|
||||||
|
* bleiben im Zwischenzustand; die Oberflaeche zeigt, was fehlt.
|
||||||
|
*/
|
||||||
|
export async function widerrufAbschliessen(
|
||||||
|
schluesselIds: readonly string[],
|
||||||
|
akteur: Akteur,
|
||||||
|
): Promise<AbschlussErgebnis[]> {
|
||||||
|
const liste = await schluesselLaden(schluesselIds);
|
||||||
|
const offen = await offeneEntfernungen(liste.map((s) => s.id));
|
||||||
|
const ergebnisse: AbschlussErgebnis[] = [];
|
||||||
|
for (const s of liste) {
|
||||||
|
if (s.status === "WIDERRUFEN" || s.status === "ROTIERT") {
|
||||||
|
ergebnisse.push({ schluesselId: s.id, name: s.name, status: s.status, offen: [] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!s.widerrufEingeleitetAm) throw new WiderrufFehler(`Für „${s.name}“ ist kein Widerruf eingeleitet.`);
|
||||||
|
const eigene = offen.filter((o) => o.schluesselId === s.id);
|
||||||
|
if (eigene.length > 0) {
|
||||||
|
ergebnisse.push({ schluesselId: s.id, name: s.name, status: "OFFEN", offen: eigene });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
|
||||||
|
const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN";
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const geaendert = await tx.schluessel.updateMany({
|
||||||
|
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
if (geaendert.count === 0) return;
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: status === "ROTIERT" ? "schluessel.rotiert" : "schluessel.widerrufen",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: s.fingerprint,
|
||||||
|
details: {
|
||||||
|
name: s.name,
|
||||||
|
...(s.nachfolger && status === "ROTIERT" ? { nachfolgerId: s.nachfolger.id } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ergebnisse.push({ schluesselId: s.id, name: s.name, status, offen: [] });
|
||||||
|
}
|
||||||
|
return ergebnisse;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||||
|
import { istGeschuetzt, sudoersInhalt, ZielkontoFehler, type SudoModus } from "@/lib/ssh/zielkonto";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zielkonten anfordern (#018). Nur Admins mit frischer Bestaetigung (Recht
|
||||||
|
* zielkontenVerwalten) – ein neuer Benutzer mit sudo ist so viel wert wie root.
|
||||||
|
*/
|
||||||
|
export function zielkontoEingabePruefen(
|
||||||
|
benutzer: string,
|
||||||
|
modus: SudoModus,
|
||||||
|
befehle: readonly string[],
|
||||||
|
): void {
|
||||||
|
if (!BENUTZERNAME_MUSTER.test(benutzer)) throw new ZielkontoFehler("Ungültiger Benutzername.");
|
||||||
|
if (istGeschuetzt(benutzer))
|
||||||
|
throw new ZielkontoFehler(`„${benutzer}“ verwaltet sshkv nicht als Zielkonto.`);
|
||||||
|
sudoersInhalt(benutzer, modus, befehle);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zielkontoAnfordern(
|
||||||
|
eingabe: { hostId: string; benutzer: string; modus: SudoModus; befehle: readonly string[] },
|
||||||
|
akteur: { id: string },
|
||||||
|
): Promise<string> {
|
||||||
|
zielkontoEingabePruefen(eingabe.benutzer, eingabe.modus, eingabe.befehle);
|
||||||
|
return vorgangEinreihen({
|
||||||
|
art: "ZIELKONTO_ANLEGEN",
|
||||||
|
angefordertVonId: akteur.id,
|
||||||
|
hostId: eingabe.hostId,
|
||||||
|
eingabe: { benutzer: eingabe.benutzer, modus: eingabe.modus, befehle: [...eingabe.befehle] },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { Client } from "ssh2";
|
||||||
|
import type { Host, Prisma } from "@/generated/prisma/client";
|
||||||
|
import type { AbweichungsArt } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { BENUTZERNAME_MUSTER, shellQuote } from "@/lib/shell";
|
||||||
|
import { authorizedKeysAbgleichen, type Befund, type DateiRechte } from "@/lib/ssh/abgleich";
|
||||||
|
import { anmeldebenutzerVon, sollFuer } from "@/server/services/soll";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||||
|
import { hostLaden } from "@/worker/ablaeufe/hostkey";
|
||||||
|
import { privateKeyLaden } from "@/worker/geheimnisse";
|
||||||
|
import { hostVerbinden } from "@/worker/ssh/host";
|
||||||
|
import { relativenPfadPruefen } from "@/worker/ssh/ssh-dateien";
|
||||||
|
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abgleich eines Hosts (#021): Ist-Zustand lesen, mit dem Soll vergleichen, Befunde und
|
||||||
|
* Zustaende festhalten. Aendert auf dem Host nichts – Korrekturen laufen ueber Dry-Run und
|
||||||
|
* Ausrollen, damit ein Mensch sie sieht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type GelesenerBenutzer = {
|
||||||
|
readonly benutzer: string;
|
||||||
|
readonly inhalt: string | null;
|
||||||
|
readonly rechte?: DateiRechte;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest authorized_keys aller Benutzer mit UID 0 oder ≥ 1000 sowie der ausdruecklich
|
||||||
|
* genannten. Inhalt als base64, damit Zeilenenden und fremde Bytes unversehrt ankommen; gelesen
|
||||||
|
* wird als der jeweilige Benutzer (Symlink-Schutz wie beim Schreiben).
|
||||||
|
*/
|
||||||
|
async function authorizedKeysAllerBenutzer(
|
||||||
|
verbindung: Client,
|
||||||
|
host: Host,
|
||||||
|
zusaetzlich: string[],
|
||||||
|
): Promise<GelesenerBenutzer[]> {
|
||||||
|
const namen = [...new Set(zusaetzlich)].filter((n) => BENUTZERNAME_MUSTER.test(n));
|
||||||
|
const skript = `set -u
|
||||||
|
LISTE=$( { getent passwd | awk -F: '($3==0 || $3>=1000) && $3!=65534 {print $1}'; printf '%s\\n' ${namen.map(shellQuote).join(" ")}; } | sort -u)
|
||||||
|
for B in $LISTE; do
|
||||||
|
H=$(getent passwd "$B" | cut -d: -f6) || continue
|
||||||
|
[ -n "$H" ] || continue
|
||||||
|
D="$H/.ssh/authorized_keys"
|
||||||
|
if runuser -u "$B" -- test -e "$D" 2>/dev/null; then
|
||||||
|
RD=$(stat -c '%a:%U' "$H/.ssh" 2>/dev/null || echo "-:-")
|
||||||
|
RF=$(stat -c '%a:%U' "$D" 2>/dev/null || echo "-:-")
|
||||||
|
INHALT=$(runuser -u "$B" -- base64 -w0 "$D" 2>/dev/null || echo FEHLER)
|
||||||
|
echo "SSHKV-DATEI|$B|$RD|$RF|$INHALT"
|
||||||
|
else
|
||||||
|
echo "SSHKV-KEINE|$B"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo SSHKV-ENDE`;
|
||||||
|
const praefix = host.anmeldeart === "ROOT" ? "" : "sudo -n ";
|
||||||
|
const ergebnis = await befehlAusfuehren(verbindung, `${praefix}sh -c ${shellQuote(skript)}`);
|
||||||
|
if (!ergebnis.stdout.includes("SSHKV-ENDE"))
|
||||||
|
throw new VorgangsFehler(`Abgleich auf ${host.name} ließ sich nicht ausführen.`);
|
||||||
|
|
||||||
|
const gelesen: GelesenerBenutzer[] = [];
|
||||||
|
for (const zeile of ergebnis.stdout.split("\n")) {
|
||||||
|
const teile = zeile.split("|");
|
||||||
|
if (teile[0] === "SSHKV-KEINE" && teile[1]) gelesen.push({ benutzer: teile[1], inhalt: null });
|
||||||
|
if (teile[0] === "SSHKV-DATEI" && teile.length === 5) {
|
||||||
|
const [, benutzer, rd, rf, inhalt] = teile as [string, string, string, string, string];
|
||||||
|
const [modusD, besitzerD] = rd.split(":");
|
||||||
|
const [modusF, besitzerF] = rf.split(":");
|
||||||
|
gelesen.push({
|
||||||
|
benutzer,
|
||||||
|
inhalt: inhalt === "FEHLER" ? null : Buffer.from(inhalt, "base64").toString("utf8"),
|
||||||
|
rechte: {
|
||||||
|
sshVerzeichnis: modusD && modusD !== "-" ? modusD : null,
|
||||||
|
datei: modusF && modusF !== "-" ? modusF : null,
|
||||||
|
eigentuemerPasst: besitzerD === benutzer && besitzerF === benutzer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gelesen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prueft ausgerollte Private Keys: Datei da und unveraendert? */
|
||||||
|
async function privateKeysPruefen(
|
||||||
|
verbindung: Client,
|
||||||
|
host: Host,
|
||||||
|
): Promise<{ befunde: (Befund & { benutzer: string })[]; ok: string[]; fehlt: string[] }> {
|
||||||
|
const verwendungen = await prisma.privateVerwendung.findMany({
|
||||||
|
where: { hostId: host.id, zustand: { in: ["AUSGEROLLT", "ABWEICHUNG"] } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
pfad: true,
|
||||||
|
schluesselId: true,
|
||||||
|
schluessel: { select: { fingerprint: true, masterKeyVersion: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const befunde: (Befund & { benutzer: string })[] = [];
|
||||||
|
const ok: string[] = [];
|
||||||
|
const fehlt: string[] = [];
|
||||||
|
const praefix = host.anmeldeart === "ROOT" ? "" : "sudo -n ";
|
||||||
|
for (const v of verwendungen) {
|
||||||
|
const relativ = relativenPfadPruefen(v.pfad);
|
||||||
|
const skript = `H=$(getent passwd ${shellQuote(v.zielbenutzer)} | cut -d: -f6); runuser -u ${shellQuote(v.zielbenutzer)} -- sha256sum "$H/"${shellQuote(relativ)} 2>/dev/null | cut -d' ' -f1`;
|
||||||
|
const ergebnis = await befehlAusfuehren(verbindung, `${praefix}sh -c ${shellQuote(skript)}`);
|
||||||
|
const ist = ergebnis.stdout.trim();
|
||||||
|
let erwartet: string | null = null;
|
||||||
|
// Ist der Private Key aus sshkv geloescht, laesst sich nur noch das Vorhandensein pruefen.
|
||||||
|
if (v.schluessel.masterKeyVersion !== null) {
|
||||||
|
const klartext = await privateKeyLaden(v.schluesselId);
|
||||||
|
erwartet = createHash("sha256").update(klartext).digest("hex");
|
||||||
|
klartext.fill(0);
|
||||||
|
}
|
||||||
|
if (!ist || (erwartet && ist !== erwartet)) {
|
||||||
|
fehlt.push(v.id);
|
||||||
|
befunde.push({
|
||||||
|
benutzer: v.zielbenutzer,
|
||||||
|
art: "FEHLENDER_SCHLUESSEL",
|
||||||
|
fingerprint: v.schluessel.fingerprint,
|
||||||
|
text: ist ? `Private Key unter ${v.pfad} wurde verändert.` : `Private Key unter ${v.pfad} fehlt.`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
ok.push(v.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { befunde, ok, fehlt };
|
||||||
|
}
|
||||||
|
|
||||||
|
function befundSchluessel(
|
||||||
|
art: string,
|
||||||
|
benutzer: string | null,
|
||||||
|
fingerprint: string | null,
|
||||||
|
text: string,
|
||||||
|
): string {
|
||||||
|
return [art, benutzer ?? "", fingerprint ?? text].join("\u0000");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostAbgleichen(host: Host): Promise<{ befunde: number; benutzer: number }> {
|
||||||
|
const publicVerwendungen = await prisma.publicVerwendung.findMany({
|
||||||
|
where: {
|
||||||
|
zustand: { not: "ENTFERNT" },
|
||||||
|
OR: [{ hostId: host.id }, { hostgruppe: { mitglieder: { some: { hostId: host.id } } } }],
|
||||||
|
},
|
||||||
|
select: { zielbenutzer: true },
|
||||||
|
});
|
||||||
|
const sollBenutzer = [
|
||||||
|
...new Set([anmeldebenutzerVon(host), ...publicVerwendungen.map((v) => v.zielbenutzer)]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const verbindung = await hostVerbinden(host);
|
||||||
|
let gelesen: GelesenerBenutzer[];
|
||||||
|
let privat: Awaited<ReturnType<typeof privateKeysPruefen>>;
|
||||||
|
try {
|
||||||
|
gelesen = await authorizedKeysAllerBenutzer(verbindung, host, sollBenutzer);
|
||||||
|
privat = await privateKeysPruefen(verbindung, host);
|
||||||
|
} finally {
|
||||||
|
verbindung.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bekannt = new Set(
|
||||||
|
(await prisma.schluessel.findMany({ select: { fingerprint: true } })).map((s) => s.fingerprint),
|
||||||
|
);
|
||||||
|
const aktuell = new Map<
|
||||||
|
string,
|
||||||
|
{ art: AbweichungsArt; benutzer: string | null; fingerprint: string | null; text: string }
|
||||||
|
>();
|
||||||
|
const jetzt = new Date();
|
||||||
|
|
||||||
|
for (const { benutzer, inhalt, rechte } of gelesen) {
|
||||||
|
const soll = await sollFuer(host.id, benutzer);
|
||||||
|
// Benutzer ohne Datei und ohne Soll interessieren nicht.
|
||||||
|
if (inhalt === null && soll.eintraege.length === 0) continue;
|
||||||
|
const ergebnis = authorizedKeysAbgleichen(inhalt, soll.eintraege, bekannt, rechte);
|
||||||
|
for (const befund of ergebnis.befunde) {
|
||||||
|
aktuell.set(befundSchluessel(befund.art, benutzer, befund.fingerprint ?? null, befund.text), {
|
||||||
|
art: befund.art,
|
||||||
|
benutzer,
|
||||||
|
fingerprint: befund.fingerprint ?? null,
|
||||||
|
text: befund.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const datei = ".ssh/authorized_keys";
|
||||||
|
await prisma.beobachtung.upsert({
|
||||||
|
where: { hostId_zielbenutzer_datei: { hostId: host.id, zielbenutzer: benutzer, datei } },
|
||||||
|
create: {
|
||||||
|
hostId: host.id,
|
||||||
|
zielbenutzer: benutzer,
|
||||||
|
datei,
|
||||||
|
sha256: inhalt === null ? "keine-datei" : createHash("sha256").update(inhalt).digest("hex"),
|
||||||
|
eintraege: ergebnis.eintraege as unknown as Prisma.InputJsonValue,
|
||||||
|
beobachtetAm: jetzt,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
sha256: inhalt === null ? "keine-datei" : createHash("sha256").update(inhalt).digest("hex"),
|
||||||
|
eintraege: ergebnis.eintraege as unknown as Prisma.InputJsonValue,
|
||||||
|
beobachtetAm: jetzt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zustand der Public-Verwendungen nachziehen: was im Block stimmt, ist synchron; was fehlt,
|
||||||
|
// obwohl es ausgerollt war, ist Drift. GEPLANT bleibt GEPLANT, bis jemand ausrollt.
|
||||||
|
const vorhanden = new Set(ergebnis.vorhanden);
|
||||||
|
const verwendungen = await prisma.publicVerwendung.findMany({
|
||||||
|
where: {
|
||||||
|
zielbenutzer: benutzer,
|
||||||
|
id: { in: soll.verwendungIds.drin },
|
||||||
|
},
|
||||||
|
select: { id: true, zustand: true, schluessel: { select: { fingerprint: true } } },
|
||||||
|
});
|
||||||
|
for (const v of verwendungen) {
|
||||||
|
const da = vorhanden.has(v.schluessel.fingerprint);
|
||||||
|
if (da && v.zustand === "ABWEICHUNG") {
|
||||||
|
await prisma.publicVerwendung.update({ where: { id: v.id }, data: { zustand: "AUSGEROLLT" } });
|
||||||
|
} else if (!da && v.zustand === "AUSGEROLLT") {
|
||||||
|
await prisma.publicVerwendung.update({ where: { id: v.id }, data: { zustand: "ABWEICHUNG" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Benutzer, die nicht mehr gelesen wurden (Datei weg), verlieren ihre Beobachtung.
|
||||||
|
await prisma.beobachtung.deleteMany({
|
||||||
|
where: {
|
||||||
|
hostId: host.id,
|
||||||
|
zielbenutzer: { notIn: gelesen.filter((g) => g.inhalt !== null).map((g) => g.benutzer) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const befund of privat.befunde) {
|
||||||
|
aktuell.set(
|
||||||
|
befundSchluessel("PRIVATE_KEY_FEHLT", befund.benutzer, befund.fingerprint ?? null, befund.text),
|
||||||
|
{
|
||||||
|
art: "PRIVATE_KEY_FEHLT",
|
||||||
|
benutzer: befund.benutzer,
|
||||||
|
fingerprint: befund.fingerprint ?? null,
|
||||||
|
text: befund.text,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prisma.privateVerwendung.updateMany({
|
||||||
|
where: { id: { in: privat.fehlt } },
|
||||||
|
data: { zustand: "ABWEICHUNG" },
|
||||||
|
});
|
||||||
|
await prisma.privateVerwendung.updateMany({
|
||||||
|
where: { id: { in: privat.ok }, zustand: "ABWEICHUNG" },
|
||||||
|
data: { zustand: "AUSGEROLLT" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Abweichungen: neue oeffnen, verschwundene schliessen. Host-Key-Abweichungen verwaltet #009.
|
||||||
|
const offen = await prisma.abweichung.findMany({
|
||||||
|
where: { hostId: host.id, behobenAm: null, art: { not: "HOSTKEY_GEAENDERT" } },
|
||||||
|
});
|
||||||
|
const offenSchluessel = new Map(
|
||||||
|
offen.map((a) => [
|
||||||
|
befundSchluessel(
|
||||||
|
a.art,
|
||||||
|
a.zielbenutzer,
|
||||||
|
a.fingerprint,
|
||||||
|
((a.details ?? {}) as { text?: string }).text ?? "",
|
||||||
|
),
|
||||||
|
a,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
for (const [schluessel, befund] of aktuell) {
|
||||||
|
if (offenSchluessel.has(schluessel)) continue;
|
||||||
|
await prisma.abweichung.create({
|
||||||
|
data: {
|
||||||
|
hostId: host.id,
|
||||||
|
zielbenutzer: befund.benutzer,
|
||||||
|
art: befund.art,
|
||||||
|
fingerprint: befund.fingerprint,
|
||||||
|
details: { text: befund.text },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const behoben = [...offenSchluessel]
|
||||||
|
.filter(([schluessel]) => !aktuell.has(schluessel))
|
||||||
|
.map(([, a]) => a.id);
|
||||||
|
await prisma.abweichung.updateMany({ where: { id: { in: behoben } }, data: { behobenAm: jetzt } });
|
||||||
|
|
||||||
|
const hostKeyOffen = await prisma.abweichung.count({
|
||||||
|
where: { hostId: host.id, behobenAm: null, art: "HOSTKEY_GEAENDERT" },
|
||||||
|
});
|
||||||
|
await prisma.host.update({
|
||||||
|
where: { id: host.id },
|
||||||
|
data: {
|
||||||
|
driftZustand: aktuell.size > 0 || hostKeyOffen > 0 ? "DRIFT" : "SYNCHRON",
|
||||||
|
letzterAbgleichAm: jetzt,
|
||||||
|
erreichbar: true,
|
||||||
|
zuletztGeprueftAm: jetzt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { befunde: aktuell.size, benutzer: gelesen.filter((g) => g.inhalt !== null).length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const abgleichHandler: Handler = async ({ vorgang }) => {
|
||||||
|
const host = await hostLaden(vorgang.hostId);
|
||||||
|
if (!host.bootstrapAm) throw new VorgangsFehler("Der Host ist noch nicht eingerichtet (Bootstrap fehlt).");
|
||||||
|
try {
|
||||||
|
const ergebnis = await hostAbgleichen(host);
|
||||||
|
await protokollieren({
|
||||||
|
akteur: "System",
|
||||||
|
aktion: "abgleich",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: host.name,
|
||||||
|
details: ergebnis,
|
||||||
|
});
|
||||||
|
return { ergebnis };
|
||||||
|
} catch (fehler) {
|
||||||
|
await prisma.host.update({
|
||||||
|
where: { id: host.id },
|
||||||
|
data: { erreichbar: false, zuletztGeprueftAm: new Date() },
|
||||||
|
});
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -146,7 +146,7 @@ export const trockenlaufHandler: Handler = async ({ vorgang }) => {
|
|||||||
return privatPlanZiel(host, benutzer, await privatPlanen(verbindung, host, benutzer));
|
return privatPlanZiel(host, benutzer, await privatPlanen(verbindung, host, benutzer));
|
||||||
const stand = await authorizedKeysHolen(verbindung, host, benutzer);
|
const stand = await authorizedKeysHolen(verbindung, host, benutzer);
|
||||||
const soll = await sollFuer(host.id, benutzer);
|
const soll = await sollFuer(host.id, benutzer);
|
||||||
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht);
|
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht, soll.ausserhalbEntfernen);
|
||||||
return {
|
return {
|
||||||
art: "public",
|
art: "public",
|
||||||
hostId: host.id,
|
hostId: host.id,
|
||||||
@@ -249,7 +249,7 @@ export const ausrollenHandler: Handler = async ({ vorgang }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const soll = await sollFuer(host.id, benutzer);
|
const soll = await sollFuer(host.id, benutzer);
|
||||||
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht);
|
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht, soll.ausserhalbEntfernen);
|
||||||
if (hashVon(ergebnis.inhalt) !== ziel.nachher) {
|
if (hashVon(ergebnis.inhalt) !== ziel.nachher) {
|
||||||
throw new VorgangsFehler(
|
throw new VorgangsFehler(
|
||||||
`Die Zuweisungen für ${benutzer}@${host.name} haben sich seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`,
|
`Die Zuweisungen für ${benutzer}@${host.name} haben sich seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`,
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ async function gewollteDateien(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dateien: Gewollt[] = [];
|
const dateien: Gewollt[] = [];
|
||||||
const config: SshConfigEintrag[] = [];
|
// Je Alias genau ein Eintrag: bei einer Rotation (#023) tragen alte und neue Zuweisung denselben
|
||||||
|
// Alias. Die juengere gewinnt (Reihenfolge nach angelegtAm) – ssh nimmt sonst den ersten Treffer,
|
||||||
|
// und der Alias zeigte weiter auf den alten Schluessel. Die Position bleibt die des ersten.
|
||||||
|
const config = new Map<string, SshConfigEintrag>();
|
||||||
const knownHosts: string[] = [];
|
const knownHosts: string[] = [];
|
||||||
for (const verwendung of verwendungen) {
|
for (const verwendung of verwendungen) {
|
||||||
const pfad = relativenPfadPruefen(verwendung.pfad);
|
const pfad = relativenPfadPruefen(verwendung.pfad);
|
||||||
@@ -100,7 +103,7 @@ async function gewollteDateien(
|
|||||||
port?: number;
|
port?: number;
|
||||||
} | null;
|
} | null;
|
||||||
if (sshConfig?.alias && sshConfig.hostName && sshConfig.benutzer) {
|
if (sshConfig?.alias && sshConfig.hostName && sshConfig.benutzer) {
|
||||||
config.push({
|
config.set(sshConfig.alias, {
|
||||||
alias: sshConfig.alias,
|
alias: sshConfig.alias,
|
||||||
hostName: sshConfig.hostName,
|
hostName: sshConfig.hostName,
|
||||||
benutzer: sshConfig.benutzer,
|
benutzer: sshConfig.benutzer,
|
||||||
@@ -127,7 +130,7 @@ async function gewollteDateien(
|
|||||||
dateien.push({
|
dateien.push({
|
||||||
art: "config",
|
art: "config",
|
||||||
pfad: ".ssh/config",
|
pfad: ".ssh/config",
|
||||||
inhalt: Buffer.from(sshConfigZeilen(config).join("\n")),
|
inhalt: Buffer.from(sshConfigZeilen([...config.values()]).join("\n")),
|
||||||
modus: "0600",
|
modus: "0600",
|
||||||
});
|
});
|
||||||
dateien.push({
|
dateien.push({
|
||||||
|
|||||||
@@ -103,12 +103,15 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
|
|||||||
vorlageId: eingabe.vorlageId,
|
vorlageId: eingabe.vorlageId,
|
||||||
erstelltVonId: akteur === "System" ? null : akteur.id,
|
erstelltVonId: akteur === "System" ? null : akteur.id,
|
||||||
passphraseGeschuetzt: Boolean(passphrase),
|
passphraseGeschuetzt: Boolean(passphrase),
|
||||||
|
vorgaengerId: eingabe.vorgaengerId ?? null,
|
||||||
...neu.versiegelt,
|
...neu.versiegelt,
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
if (istEindeutigkeitsFehler(fehler)) {
|
if (istEindeutigkeitsFehler(fehler)) {
|
||||||
|
// vorgaengerId ist eindeutig: zwei Rotationen desselben Schluessels gleichzeitig (#023).
|
||||||
|
if (eingabe.vorgaengerId) throw new VorgangsFehler("Für diesen Schlüssel läuft bereits eine Rotation.");
|
||||||
throw new VorgangsFehler(
|
throw new VorgangsFehler(
|
||||||
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
|
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
|
||||||
);
|
);
|
||||||
@@ -127,6 +130,7 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
|
|||||||
typ,
|
typ,
|
||||||
kategorie: "DIENST",
|
kategorie: "DIENST",
|
||||||
passphraseGeschuetzt: Boolean(passphrase),
|
passphraseGeschuetzt: Boolean(passphrase),
|
||||||
|
...(eingabe.vorgaengerId ? { vorgaengerId: eingabe.vorgaengerId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } };
|
return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } };
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { sudoersDatei, sudoersInhalt, type SudoModus } from "@/lib/ssh/zielkonto";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||||
|
import { hostLaden } from "@/worker/ablaeufe/hostkey";
|
||||||
|
import { hostVerbinden } from "@/worker/ssh/host";
|
||||||
|
import { zielkontoAnwenden, zielkontoEntfernen } from "@/worker/ssh/zielkonto";
|
||||||
|
|
||||||
|
/** Vorgaenge ZIELKONTO_ANLEGEN und ZIELKONTO_ENTFERNEN (#018). */
|
||||||
|
type Eingabe = { benutzer?: unknown; modus?: unknown; befehle?: unknown };
|
||||||
|
|
||||||
|
function eingabeLesen(roh: unknown): { benutzer: string; modus: SudoModus; befehle: string[] } {
|
||||||
|
const e = (roh ?? {}) as Eingabe;
|
||||||
|
if (typeof e.benutzer !== "string") throw new VorgangsFehler("Kein Benutzer angegeben.");
|
||||||
|
const modus = (["KEINER", "BEFEHLE", "VOLL"] as const).find((m) => m === e.modus) ?? "KEINER";
|
||||||
|
const befehle = Array.isArray(e.befehle) ? e.befehle.filter((b): b is string => typeof b === "string") : [];
|
||||||
|
return { benutzer: e.benutzer, modus, befehle };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function akteurVon(id: string | null) {
|
||||||
|
const benutzer = id
|
||||||
|
? await prisma.benutzer.findUnique({ where: { id }, select: { id: true, name: true } })
|
||||||
|
: null;
|
||||||
|
return benutzer ?? ("System" as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const zielkontoAnlegenHandler: Handler = async ({ vorgang }) => {
|
||||||
|
const host = await hostLaden(vorgang.hostId);
|
||||||
|
const konto = eingabeLesen(vorgang.eingabe);
|
||||||
|
const verbindung = await hostVerbinden(host);
|
||||||
|
let angelegt: boolean;
|
||||||
|
try {
|
||||||
|
({ angelegt } = await zielkontoAnwenden(verbindung, host, konto));
|
||||||
|
} finally {
|
||||||
|
verbindung.end();
|
||||||
|
}
|
||||||
|
const inhalt = sudoersInhalt(konto.benutzer, konto.modus, konto.befehle);
|
||||||
|
const vorher = await prisma.zielkonto.findUnique({
|
||||||
|
where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } },
|
||||||
|
});
|
||||||
|
await prisma.zielkonto.upsert({
|
||||||
|
where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } },
|
||||||
|
create: {
|
||||||
|
hostId: host.id,
|
||||||
|
benutzername: konto.benutzer,
|
||||||
|
vonSshkvAngelegt: angelegt,
|
||||||
|
sudoModus: konto.modus,
|
||||||
|
sudoBefehle: konto.befehle,
|
||||||
|
sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null,
|
||||||
|
sudoersInhalt: inhalt,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
// Wer den Benutzer einmal angelegt hat, bleibt dabei – sonst loeschte ein Widerruf ihn nie.
|
||||||
|
vonSshkvAngelegt: (vorher?.vonSshkvAngelegt && !vorher.entferntAm) || angelegt,
|
||||||
|
sudoModus: konto.modus,
|
||||||
|
sudoBefehle: konto.befehle,
|
||||||
|
sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null,
|
||||||
|
sudoersInhalt: inhalt,
|
||||||
|
entferntAm: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur: await akteurVon(vorgang.angefordertVonId),
|
||||||
|
aktion: angelegt ? "zielkonto.angelegt" : "zielkonto.geaendert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: host.name,
|
||||||
|
zielbenutzer: konto.benutzer,
|
||||||
|
diff: inhalt ?? "(keine sudoers-Datei)",
|
||||||
|
details: { sudoModus: konto.modus },
|
||||||
|
});
|
||||||
|
return { ergebnis: { angelegt, benutzer: konto.benutzer } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const zielkontoEntfernenHandler: Handler = async ({ vorgang }) => {
|
||||||
|
const host = await hostLaden(vorgang.hostId);
|
||||||
|
const { benutzer } = eingabeLesen(vorgang.eingabe);
|
||||||
|
const konto = await prisma.zielkonto.findUnique({
|
||||||
|
where: { hostId_benutzername: { hostId: host.id, benutzername: benutzer } },
|
||||||
|
});
|
||||||
|
if (!konto || konto.entferntAm) throw new VorgangsFehler("Dieses Zielkonto verwaltet sshkv nicht (mehr).");
|
||||||
|
const verbindung = await hostVerbinden(host);
|
||||||
|
try {
|
||||||
|
// Den Benutzer selbst nur loeschen, wenn sshkv ihn angelegt hat.
|
||||||
|
await zielkontoEntfernen(verbindung, host, benutzer, konto.vonSshkvAngelegt);
|
||||||
|
} finally {
|
||||||
|
verbindung.end();
|
||||||
|
}
|
||||||
|
await prisma.zielkonto.update({ where: { id: konto.id }, data: { entferntAm: new Date() } });
|
||||||
|
await protokollieren({
|
||||||
|
akteur: await akteurVon(vorgang.angefordertVonId),
|
||||||
|
aktion: "zielkonto.entfernt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: host.name,
|
||||||
|
zielbenutzer: benutzer,
|
||||||
|
details: { benutzerGeloescht: konto.vonSshkvAngelegt },
|
||||||
|
});
|
||||||
|
return { ergebnis: { benutzerGeloescht: konto.vonSshkvAngelegt } };
|
||||||
|
};
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||||
|
import { abgleichHandler } from "@/worker/ablaeufe/abgleich";
|
||||||
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
||||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||||
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
||||||
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
||||||
import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
|
import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
|
||||||
|
import { zielkontoAnlegenHandler, zielkontoEntfernenHandler } from "@/worker/ablaeufe/zielkonto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
|
* Welche Vorgangsart wer ausfuehrt.
|
||||||
*/
|
*/
|
||||||
export const HANDLER: HandlerTabelle = {
|
export const HANDLER: HandlerTabelle = {
|
||||||
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
||||||
@@ -20,4 +22,7 @@ export const HANDLER: HandlerTabelle = {
|
|||||||
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
||||||
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
||||||
VERBINDUNGSTEST: verbindungstestHandler,
|
VERBINDUNGSTEST: verbindungstestHandler,
|
||||||
|
ABGLEICH: abgleichHandler,
|
||||||
|
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
||||||
|
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import type { Job } from "pg-boss";
|
import type { Job } from "pg-boss";
|
||||||
import { VERSION } from "@/lib/version";
|
import { VERSION } from "@/lib/version";
|
||||||
import { WARTESCHLANGE, warteschlangeHolen, type VorgangsJob } from "@/server/vorgaenge/warteschlange";
|
import { alleHostsAbgleichen } from "@/server/services/abgleich";
|
||||||
|
import {
|
||||||
|
WARTESCHLANGE,
|
||||||
|
ZEITPLAN_ABGLEICH,
|
||||||
|
warteschlangeHolen,
|
||||||
|
type VorgangsJob,
|
||||||
|
} from "@/server/vorgaenge/warteschlange";
|
||||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||||
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
||||||
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
||||||
@@ -45,7 +51,17 @@ async function starten(): Promise<void> {
|
|||||||
await aufraeumen();
|
await aufraeumen();
|
||||||
const takt = setInterval(() => void aufraeumen(), 30_000);
|
const takt = setInterval(() => void aufraeumen(), 30_000);
|
||||||
|
|
||||||
console.log(`sshkv-worker ${VERSION} bereit.`);
|
// Periodischer Abgleich (#021). Der Zeitplan liegt in pg-boss und damit in der Datenbank:
|
||||||
|
// laufen spaeter mehrere Worker, feuert er trotzdem nur einmal.
|
||||||
|
const zeitplan = process.env["SSHKV_SYNC_CRON"]?.trim() || "30 2 * * *";
|
||||||
|
await boss.createQueue(ZEITPLAN_ABGLEICH);
|
||||||
|
await boss.schedule(ZEITPLAN_ABGLEICH, zeitplan, null, { tz: "Europe/Berlin" });
|
||||||
|
await boss.work(ZEITPLAN_ABGLEICH, async () => {
|
||||||
|
const anzahl = await alleHostsAbgleichen(null);
|
||||||
|
console.log(`Zeitplan: ${anzahl} Abgleich(e) eingereiht.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`sshkv-worker ${VERSION} bereit (Abgleich: ${zeitplan}).`);
|
||||||
|
|
||||||
const beenden = async (signal: string) => {
|
const beenden = async (signal: string) => {
|
||||||
console.log(`${signal} empfangen, beende laufende Vorgänge …`);
|
console.log(`${signal} empfangen, beende laufende Vorgänge …`);
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import "dotenv/config";
|
||||||
|
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abgleich und Uebernahme im Browser (#021, #022), gebauter Stand + Worker, Testziel B:
|
||||||
|
* fremder Key → „Jetzt abgleichen“ zeigt ihn → übernehmen → Dry-Run → anwenden → synchron.
|
||||||
|
*/
|
||||||
|
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
|
||||||
|
const PORT_B = (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222";
|
||||||
|
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
function aufZielB(befehl: string): string {
|
||||||
|
return execFileSync(
|
||||||
|
"docker",
|
||||||
|
["compose", "-f", "docker-compose.test.yml", "exec", "-T", "ziel-b", "sh", "-c", befehl],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(() => {
|
||||||
|
aufZielB(
|
||||||
|
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fremden Schlüssel finden und übernehmen", async ({ page }) => {
|
||||||
|
const name = `zz-e2e-${Date.now().toString(36)}`;
|
||||||
|
await page.goto("/anmelden");
|
||||||
|
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||||
|
await page.locator('button[data-rolle="admin"]').click();
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
|
||||||
|
await page.goto("/hosts/neu");
|
||||||
|
await page.getByLabel("Name").fill(name);
|
||||||
|
await page.getByLabel("Adresse").fill("127.0.0.1");
|
||||||
|
await page.getByLabel("Port").fill(PORT_B);
|
||||||
|
await page.getByRole("button", { name: "Host anlegen" }).click();
|
||||||
|
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
|
||||||
|
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
|
||||||
|
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
|
||||||
|
await page.getByRole("button", { name: "Zugang einrichten" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: `${name}@laptop` });
|
||||||
|
aufZielB(`mkdir -p /root/.ssh && echo '${fremd.publicKeyZeile}' >> /root/.ssh/authorized_keys`);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Jetzt abgleichen" }).click();
|
||||||
|
await expect(page.getByText(/● Unbekannter Schlüssel/)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("unbekannt", { exact: true })).toBeVisible();
|
||||||
|
await page.screenshot({ path: "docs/anleitung/bilder/abgleich-desktop.png", fullPage: true });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Übernehmen", exact: true }).click();
|
||||||
|
const dialog = page.getByRole("dialog", { name: "Schlüssel übernehmen" });
|
||||||
|
await dialog.getByLabel("Zweck / Name").fill(`${name} Laptop`);
|
||||||
|
await dialog.getByRole("button", { name: "Übernehmen" }).click();
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
await expect(page.getByText(`root@${name}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||||
|
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
|
||||||
|
expect(aufZielB("grep -c laptop /root/.ssh/authorized_keys").trim()).toBe("1");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Jetzt abgleichen" }).click();
|
||||||
|
await expect(page.getByText("Keine offenen Abweichungen.")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Host archivieren" }).click();
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host-zu-Host-Assistent mit der Vorlage „Deployment-Zugang“ (#018–#020), gebauter Stand +
|
||||||
|
* Worker: Benutzer deploy auf B anlegen, Private Key auf A, Public Key auf B, Dry-Run,
|
||||||
|
* ausrollen, Verbindung testen.
|
||||||
|
*/
|
||||||
|
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
|
||||||
|
const PORT = {
|
||||||
|
a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221",
|
||||||
|
b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222",
|
||||||
|
};
|
||||||
|
|
||||||
|
test.setTimeout(240_000);
|
||||||
|
|
||||||
|
function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string {
|
||||||
|
return execFileSync(
|
||||||
|
"docker",
|
||||||
|
["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostEinrichten(page: Page, name: string, port: string) {
|
||||||
|
await page.goto("/hosts/neu");
|
||||||
|
await page.getByLabel("Name").fill(name);
|
||||||
|
await page.getByLabel("Adresse").fill("127.0.0.1");
|
||||||
|
await page.getByLabel("Port").fill(port);
|
||||||
|
await page.getByRole("button", { name: "Host anlegen" }).click();
|
||||||
|
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
|
||||||
|
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
|
||||||
|
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
|
||||||
|
await page.getByRole("button", { name: "Zugang einrichten" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(() => {
|
||||||
|
const reset =
|
||||||
|
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
|
||||||
|
aufZiel("ziel-a", reset);
|
||||||
|
aufZiel("ziel-b", reset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Deployment-Zugang mit dem Assistenten", async ({ page }) => {
|
||||||
|
const kennung = Date.now().toString(36);
|
||||||
|
const hostA = `zz-e2e-a-${kennung}`;
|
||||||
|
const hostB = `zz-e2e-b-${kennung}`;
|
||||||
|
|
||||||
|
await page.goto("/anmelden");
|
||||||
|
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||||
|
await page.locator('button[data-rolle="admin"]').click();
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
await hostEinrichten(page, hostA, PORT.a);
|
||||||
|
await hostEinrichten(page, hostB, PORT.b);
|
||||||
|
|
||||||
|
// Frische Bestaetigung vorab (Benutzer anlegen verlangt sie); der Mock braucht auth_time.
|
||||||
|
await page.goto("/bestaetigen?ziel=%2Fassistent");
|
||||||
|
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||||
|
await page.locator('button[data-rolle="admin"]').click();
|
||||||
|
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||||
|
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Host-zu-Host-Assistent" })).toBeVisible();
|
||||||
|
|
||||||
|
// 1. Vorlage & Zweck
|
||||||
|
await page.getByLabel("Vorlage").selectOption({ label: "Deployment-Zugang" });
|
||||||
|
await page.getByLabel("Zweck").fill(`zz-e2e Claude Code Deployment ${kennung}`);
|
||||||
|
await expect(page.getByLabel("Ablaufdatum (optional)")).not.toHaveValue("");
|
||||||
|
await page.getByRole("button", { name: "Weiter" }).click();
|
||||||
|
|
||||||
|
// 2. Quelle
|
||||||
|
await page.getByLabel("Host A").selectOption({ label: hostA });
|
||||||
|
await page.getByRole("button", { name: "Weiter" }).click();
|
||||||
|
|
||||||
|
// 3. Ziel
|
||||||
|
await page.getByLabel("Host B").selectOption({ label: hostB });
|
||||||
|
await expect(page.getByLabel("Benutzer auf B")).toHaveValue("deploy");
|
||||||
|
await expect(page.getByLabel("Einschränkungen in authorized_keys")).toHaveValue('from="127.0.0.1"');
|
||||||
|
await expect(page.getByLabel("Alias auf A")).toHaveValue(`deploy-${hostB}`);
|
||||||
|
// Im Docker-Netz kommt A nicht von 127.0.0.1 und erreicht B unter seinem Dienstnamen.
|
||||||
|
await page.getByLabel("Einschränkungen in authorized_keys").fill("no-agent-forwarding");
|
||||||
|
await page.getByLabel("HostName (aus Sicht von A)").fill("ziel-b");
|
||||||
|
await page.getByLabel("Port").fill("22");
|
||||||
|
await page.getByText("Fingerprint von B ist bestätigt").click();
|
||||||
|
await page.screenshot({ path: "docs/anleitung/bilder/assistent-ziel-desktop.png", fullPage: true });
|
||||||
|
await page.getByRole("button", { name: "Weiter" }).click();
|
||||||
|
|
||||||
|
// 4. Pruefen und vorbereiten
|
||||||
|
await expect(page.getByText(`„deploy“ auf ${hostB} anlegen`)).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Vorbereiten" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 90_000 });
|
||||||
|
|
||||||
|
// 5. Dry-Run, ausrollen, testen
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
await expect(page.getByText(`deploy@${hostB}:/home/deploy/.ssh/authorized_keys`)).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||||
|
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
|
||||||
|
await page.getByRole("button", { name: "Verbindung von A nach B testen" }).click();
|
||||||
|
await expect(page.getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.screenshot({ path: "docs/anleitung/bilder/assistent-fertig-desktop.png", fullPage: true });
|
||||||
|
expect(aufZiel("ziel-a", `ssh -o BatchMode=yes deploy-${hostB} id -un`).trim()).toBe("deploy");
|
||||||
|
|
||||||
|
for (const name of [hostA, hostB]) {
|
||||||
|
await page.goto("/hosts");
|
||||||
|
await page.getByRole("link", { name }).first().click();
|
||||||
|
await page.getByRole("button", { name: "Host archivieren" }).click();
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/hosts$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemeinsame Schritte fuer Widerruf (#024) und Rotation (#023): anmelden, Hosts einrichten und
|
||||||
|
* der Kern-Anwendungsfall aus kern-anwendungsfall.spec.ts als Ausgangslage.
|
||||||
|
*/
|
||||||
|
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
|
||||||
|
export const PORT = {
|
||||||
|
a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221",
|
||||||
|
b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string {
|
||||||
|
return execFileSync(
|
||||||
|
"docker",
|
||||||
|
["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zieleZuruecksetzen(): void {
|
||||||
|
const reset =
|
||||||
|
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
|
||||||
|
aufZiel("ziel-a", reset);
|
||||||
|
aufZiel("ziel-b", reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function anmelden(page: Page): Promise<void> {
|
||||||
|
await page.goto("/anmelden");
|
||||||
|
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||||
|
await page.locator('button[data-rolle="admin"]').click();
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hostEinrichten(page: Page, name: string, port: string): Promise<void> {
|
||||||
|
await page.goto("/hosts/neu");
|
||||||
|
await page.getByLabel("Name").fill(name);
|
||||||
|
await page.getByLabel("Adresse").fill("127.0.0.1");
|
||||||
|
await page.getByLabel("Port").fill(port);
|
||||||
|
await page.getByRole("button", { name: "Host anlegen" }).click();
|
||||||
|
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
|
||||||
|
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
|
||||||
|
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
|
||||||
|
await page.getByRole("button", { name: "Zugang einrichten" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archivieren(page: Page, name: string): Promise<void> {
|
||||||
|
await page.goto("/hosts");
|
||||||
|
await page.getByRole("link", { name }).first().click();
|
||||||
|
await page.getByRole("button", { name: "Host archivieren" }).click();
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/hosts$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kern-Anwendungsfall: Schluessel erzeugen, Private Key auf A (Alias zu-b), Public Key auf B,
|
||||||
|
* Dry-Run, anwenden. Endet auf der Schluesselseite; liefert deren Adresse.
|
||||||
|
*/
|
||||||
|
export async function kernAufbauen(page: Page, zweck: string, hostA: string, hostB: string): Promise<string> {
|
||||||
|
await page.goto("/schluessel/neu");
|
||||||
|
await page.getByLabel("Zweck").fill(zweck);
|
||||||
|
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Ausrollen" }).first().click();
|
||||||
|
const privat = page.getByRole("dialog", { name: "Private Key ausrollen" });
|
||||||
|
await privat.getByLabel("Quell-Host (bekommt den Private Key)").selectOption({ label: hostA });
|
||||||
|
await privat.getByLabel("Ziel-Host (für known_hosts)").selectOption({ label: hostB });
|
||||||
|
await privat.getByRole("textbox", { name: "Alias" }).fill("zu-b");
|
||||||
|
await privat.getByRole("textbox", { name: "HostName" }).fill("ziel-b");
|
||||||
|
await privat.getByRole("spinbutton", { name: "Port" }).fill("22");
|
||||||
|
await privat.getByRole("button", { name: "Zuweisen" }).click();
|
||||||
|
await expect(privat).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Ausrollen" }).nth(1).click();
|
||||||
|
const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" });
|
||||||
|
await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: hostB });
|
||||||
|
await oeffentlich.getByRole("button", { name: "Zuweisen" }).click();
|
||||||
|
await expect(oeffentlich).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
|
||||||
|
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
|
||||||
|
return page.url();
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
anmelden,
|
||||||
|
archivieren,
|
||||||
|
aufZiel,
|
||||||
|
hostEinrichten,
|
||||||
|
kernAufbauen,
|
||||||
|
PORT,
|
||||||
|
zieleZuruecksetzen,
|
||||||
|
} from "./hilfen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gefuehrte Rotation im Browser (#023), gebauter Stand + Worker: Ausgangslage ist der
|
||||||
|
* Kern-Anwendungsfall. Neuer Schluessel → Zuweisungen uebernehmen → ausrollen → Verbindung
|
||||||
|
* testen → alten widerrufen. `ssh zu-b` von A funktioniert vorher und nachher.
|
||||||
|
*/
|
||||||
|
test.setTimeout(300_000);
|
||||||
|
|
||||||
|
test.beforeAll(() => zieleZuruecksetzen());
|
||||||
|
|
||||||
|
test("Rotation mit Schrittanzeige", async ({ page }) => {
|
||||||
|
const kennung = Date.now().toString(36);
|
||||||
|
const hostA = `zz-e2e-a-${kennung}`;
|
||||||
|
const hostB = `zz-e2e-b-${kennung}`;
|
||||||
|
const zweck = `zz-e2e Rotation ${kennung}`;
|
||||||
|
|
||||||
|
await anmelden(page);
|
||||||
|
await hostEinrichten(page, hostA, PORT.a);
|
||||||
|
await hostEinrichten(page, hostB, PORT.b);
|
||||||
|
await kernAufbauen(page, zweck, hostA, hostB);
|
||||||
|
const altDateien = aufZiel("ziel-a", "ls /root/.ssh");
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "Rotieren" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: `„${zweck}“ rotieren` })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click();
|
||||||
|
|
||||||
|
// Schritt 3: neuen Schluessel ausrollen.
|
||||||
|
await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 60_000 });
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
// Derselbe Alias, neue Datei: nur die IdentityFile-Zeile aendert sich.
|
||||||
|
await expect(page.getByText(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.screenshot({ path: "docs/anleitung/bilder/rotation-desktop.png", fullPage: true });
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||||
|
|
||||||
|
// Schritt 4: Verbindungstest mit dem neuen Schluessel.
|
||||||
|
await page.getByRole("button", { name: "Verbindungen testen" }).click({ timeout: 60_000 });
|
||||||
|
|
||||||
|
// Schritt 5: alten Schluessel widerrufen.
|
||||||
|
await page.getByRole("button", { name: "Alten Schlüssel widerrufen" }).click({ timeout: 60_000 });
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 30_000 });
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||||
|
await expect(page.getByRole("heading", { name: "Rotation abgeschlossen" })).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
|
||||||
|
const neueDateien = aufZiel("ziel-a", "ls /root/.ssh");
|
||||||
|
const alterKey = altDateien.split("\n").find((d) => d.startsWith("id_ed25519") && !d.endsWith(".pub"))!;
|
||||||
|
expect(neueDateien.split("\n")).not.toContain(alterKey);
|
||||||
|
expect(neueDateien).toMatch(/-r\d{8}\n/);
|
||||||
|
|
||||||
|
// Kette auf der Seite des neuen Schluessels.
|
||||||
|
await page.getByRole("link", { name: /\(rotiert / }).click();
|
||||||
|
const kette = page.getByRole("list", { name: "Rotationskette" });
|
||||||
|
await expect(kette.getByRole("link", { name: zweck })).toBeVisible();
|
||||||
|
await expect(kette.getByText("rotiert", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await archivieren(page, hostA);
|
||||||
|
await archivieren(page, hostB);
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorlagen im gebauten Stand (#019). Testvorlagen heissen zz-e2e-… und werden am Ende des
|
||||||
|
* Ablaufs archiviert, damit die Entwicklungsdatenbank sauber bleibt.
|
||||||
|
*/
|
||||||
|
async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") {
|
||||||
|
await page.goto("/anmelden");
|
||||||
|
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||||
|
await page.locator(`button[data-rolle="${rolle}"]`).click();
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Admin legt eine Vorlage an, sieht Feldfehler und archiviert sie", async ({ page }) => {
|
||||||
|
const name = `zz-e2e-${Date.now().toString(36)}`;
|
||||||
|
await oidcAnmelden(page, "admin");
|
||||||
|
await page.goto("/vorlagen");
|
||||||
|
await expect(page.getByRole("heading", { name: "Deployment-Zugang" })).toBeVisible();
|
||||||
|
await page.getByRole("link", { name: "Vorlage anlegen" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/vorlagen\/neu$/);
|
||||||
|
|
||||||
|
await page.getByLabel("Name").fill(name);
|
||||||
|
await page.getByLabel("Zielbenutzer").fill("zzdeploy");
|
||||||
|
await page.getByRole("checkbox", { name: /Benutzer anlegen/ }).check();
|
||||||
|
await page.getByLabel("authorized_keys-Optionen (optional)").fill('from="{{quellIp}}",no-pty');
|
||||||
|
|
||||||
|
// Die Vorschau meldet den Fehler schon vor dem Absenden, der Server dann am Feld.
|
||||||
|
await expect(page.getByText("Unbekannter Platzhalter {{quellIp}}.").first()).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Vorlage anlegen" }).click();
|
||||||
|
await expect(page.locator("#feld-authorizedKeysOptionen-fehler")).toHaveText(
|
||||||
|
"Unbekannter Platzhalter {{quellIp}}.",
|
||||||
|
);
|
||||||
|
// Eingaben bleiben nach der Feldmeldung stehen.
|
||||||
|
await expect(page.getByLabel("Name")).toHaveValue(name);
|
||||||
|
|
||||||
|
await page.getByLabel("authorized_keys-Optionen (optional)").fill('from="{{quellHostIp}}",no-pty');
|
||||||
|
await page.getByLabel("Alias-Muster für ~/.ssh/config (optional)").fill("zz-{{zielHost}}");
|
||||||
|
await expect(page.getByText('from="10.0.0.5",no-pty')).toBeVisible();
|
||||||
|
await expect(page.getByText("zz-srv-web01")).toBeVisible();
|
||||||
|
|
||||||
|
// sudo „bestimmte Befehle“ blendet das Befehlsfeld ein, „voll“ warnt.
|
||||||
|
await page.getByRole("radio", { name: "voll" }).check();
|
||||||
|
await expect(page.getByRole("note")).toContainText("root");
|
||||||
|
await page.getByRole("radio", { name: "bestimmte Befehle" }).check();
|
||||||
|
await page.getByLabel("Erlaubte Befehle").fill("/usr/bin/systemctl restart nginx");
|
||||||
|
await expect(page.getByLabel("sudoers-Inhalt", { exact: true })).toContainText(
|
||||||
|
"zzdeploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx",
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Vorlage anlegen" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/vorlagen$/);
|
||||||
|
const karte = page.locator("article", { has: page.getByRole("heading", { name }) });
|
||||||
|
await expect(karte).toBeVisible();
|
||||||
|
await expect(karte.getByText('from="{{quellHostIp}}",no-pty')).toBeVisible();
|
||||||
|
await expect(karte.getByText("wird angelegt")).toBeVisible();
|
||||||
|
|
||||||
|
await karte.getByRole("link", { name: `${name} bearbeiten` }).click();
|
||||||
|
await page.getByRole("button", { name: "Vorlage archivieren" }).click();
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: "Archivieren" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/vorlagen$/);
|
||||||
|
await expect(page.getByRole("heading", { name })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("eingebaute Vorlagen haben keinen Archivieren-Knopf", async ({ page }) => {
|
||||||
|
await oidcAnmelden(page, "admin");
|
||||||
|
await page.goto("/vorlagen");
|
||||||
|
await page.getByRole("link", { name: "Deployment-Zugang bearbeiten" }).click();
|
||||||
|
await expect(page.getByLabel("Name")).toHaveValue("Deployment-Zugang");
|
||||||
|
await expect(page.getByRole("button", { name: "Vorlage archivieren" })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Leser sieht Vorlagen, darf aber keine anlegen oder bearbeiten", async ({ page }) => {
|
||||||
|
await oidcAnmelden(page, "reader");
|
||||||
|
await page.goto("/vorlagen");
|
||||||
|
await expect(page.getByRole("heading", { name: "Deployment-Zugang" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Vorlage anlegen" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("link", { name: /bearbeiten/ })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto("/vorlagen/neu");
|
||||||
|
await expect(page).toHaveURL(/\/kein-zugriff/);
|
||||||
|
});
|
||||||