13 Commits
Author SHA1 Message Date
florian.wilde 83bd9e0922 Merge branch 'phase/release-0.4.0' 2026-10-12 13:40:34 +02:00
florian.wildeandClaude Opus 5.5 18f72f9d03 chore(release): 0.4.0 - Phase 4 abgeschlossen
Abgleich und Drift-Erkennung, Import, Rotation, Widerruf und
Zuweisungsmatrix. CHANGELOG und DEVLOG nachgetragen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:40:32 +02:00
florian.wilde 6407a2815d Merge branch 'phase/023-024' (Rotation, Widerruf) 2026-10-12 13:31:47 +02:00
florian.wildeandClaude Opus 5.5 f6ed66fc7e feat(rotation): gefuehrte Rotation mit Test vor dem Widerruf (#023)
Einen Schlüssel zu tauschen soll keinen Ausfall riskieren: Der Nachfolger
übernimmt alle aktiven Zuweisungen, wird ausgerollt und per
Verbindungstest geprüft – erst wenn alle Tests seit dem letzten Ausrollen
grün sind, entfernt der Widerruf aus #024 den alten Schlüssel, der dann
als ROTIERT endet.

Private Keys bekommen einen neuen Pfad (-rJJJJMMTT) bei gleichem Alias.
Damit ssh den neuen nimmt, schreibt privat.ts je Alias nur noch einen
config-Eintrag; die jüngste Zuweisung gewinnt. Der Worker setzt
vorgaengerId schon beim Erzeugen, so ist die Kette auch nach einem
Abbruch vollständig und die Schrittfolge wiederaufnehmbar.

Personen-Keys ohne Private Key in sshkv bekommen nur einen Hinweis:
dafür braucht es einen neuen Public Key von der Person.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:30:39 +02:00
florian.wildeandClaude Opus 5.5 a2d4f8428a fix(widerruf): gesperrte Auswahl im Massenwiderruf erkennbar (#024)
Deployment-Key, widerrufene und rotierte Schlüssel sind nicht wählbar,
ihre Kästchen sahen aber genauso aus wie die wählbaren. Jetzt sind sie
sichtbar abgeblendet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:30:04 +02:00
florian.wildeandClaude Opus 5.5 c6361ff3c6 feat(widerruf): Widerruf und Massenwiderruf mit Dry-Run (#024)
Ein Schlüssel soll nach Projektende mit einem Klick von allen Hosts
verschwinden – aber nie als „widerrufen“ gelten, solange er irgendwo noch
liegt. Deshalb ein Zwischenzustand (widerrufEingeleitetAm): Verwendungen
werden zum Entfernen vorgemerkt, der bestehende Dry-Run mit Hash-Sperre
zeigt und schreibt die Änderungen, und erst wenn keine Verwendung mehr
offen ist, setzt der Abschluss WIDERRUFEN (bzw. ROTIERT für #023).
Scheitert ein Host, bleibt der Schlüssel im Zwischenzustand und die Seite
nennt die offenen Ziele.

Zielkonten lassen sich optional mit entfernen, aber erst nach den
Schlüsseln und nie, wenn ein anderer aktiver Schlüssel sie noch nutzt.
Massenwiderruf (Admin, frische Bestätigung) nutzt einen gemeinsamen
Dry-Run und protokolliert je Schlüssel. Abgelaufene Schlüssel erscheinen
im Filter „abgelaufen“ und bieten den Widerruf an – entfernt wird nie
automatisch.

Migration 20261012130000_widerruf_eingeleitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:14:01 +02:00
florian.wilde 34f5b1f6df Merge branch 'phase/025-zuweisungsmatrix' 2026-10-12 13:06:33 +02:00
florian.wilde fd00ef6de8 Merge branch 'phase/030-gitea-abgleich' 2026-10-12 13:06:32 +02:00
florian.wildeandClaude Opus 5.5 8fadb84923 chore(gitea): Abgleich der Issue-Dateien nach Gitea vorbereiten (#030)
Skript aus ProxLedger uebernommen: legt Labels, Meilensteine und
Issues an bzw. aktualisiert sie, Token aus GITEA_TOKEN oder den
Git-Zugangsdaten. Ohne Zugang laeuft nur ein Trockenlauf - der Gitea-
Zugang fehlt noch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:06:28 +02:00
florian.wilde 656dcb3b4c Merge branch 'phase/021-abgleich' 2026-10-12 13:05:24 +02:00
florian.wildeandClaude Opus 5.5 cbeb0235f2 test(abgleich): Abgleich und Uebernahme im Browser (#021, #022)
Gebauter Stand mit Worker: fremder Key auf dem Testziel, Abgleich
zeigt ihn als unbekannt, Uebernahme in den Block mit Dry-Run, danach
keine offenen Abweichungen. Der Test raeumt einen Benutzer deploy aus
frueheren Laeufen weg - genau den hat der Abgleich zu Recht als von
Hand geaenderten Block gemeldet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:05:20 +02:00
florian.wildeandClaude Opus 5.5 a47cae4082 feat(zuweisungen): Matrix Schluessel x Host mit CSV-Export (#025)
Bisher liess sich nur je Schluessel oder je Host nachsehen, wo ein Key
verwaltet ist. Die Matrix zeigt das auf einen Blick, loest
Hostgruppen-Zuweisungen je Mitglied auf (P vs. P·G) und macht nicht
synchrone Zustaende ohne Farbe erkennbar.

Die Matrix plant nur: Zuweisen und Entfernen laufen ueber die
vorhandenen Aktionen, ausgerollt wird weiter per Dry-Run auf der
Schluesselseite. Der Aufbau ist eine reine Funktion in src/lib, damit
Seite und CSV-Export garantiert dasselbe zeigen und die
Gruppenaufloesung ohne Datenbank testbar ist. Der CSV-Export entschaerft
Formelbeginne, weil Schluesselnamen frei waehlbar sind und Excel sie
sonst ausfuehren wuerde.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:05:12 +02:00
florian.wildeandClaude Opus 5.5 e518e75022 feat(abgleich): Drift-Erkennung, Zeitplan und Uebernahme (#021, #022)
Ein Abgleich liest je Host alle authorized_keys in einem SSH-Aufruf,
als jeweiliger Benutzer, und vergleicht sie mit dem Soll: fehlende,
von Hand geaenderte und unbekannte Keys, falsche Rechte, beschaedigte
Bloecke und veraenderte Private Keys. Befunde werden geoeffnet und
wieder geschlossen, Zustaende von Verwendungen und Host nachgezogen.
Auf dem Host aendert der Abgleich nichts.

Der Zeitplan liegt in pg-boss und feuert auch bei mehreren Workern
nur einmal. Auf der Hostseite stehen Abweichungen und der Ist-Zustand
je Benutzer; unbekannte Keys lassen sich uebernehmen - nur bekannt
machen oder mit Dry-Run in den verwalteten Block holen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:02:11 +02:00
90 changed files with 7037 additions and 141 deletions
+18
View File
@@ -15,6 +15,24 @@ 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 ## [0.3.0] – 2026-10-12
Phase 3: Host-zu-Host – Private Keys, Zielkonten, Vorlagen und Assistent. Phase 3: Host-zu-Host – Private Keys, Zielkonten, Vorlagen und Assistent.
+27 -2
View File
@@ -2,8 +2,8 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt (Wandzeit):** 2 h 55 min **Gesamt (Wandzeit):** 3 h 40 min
**Summe der Issue-Zeiten:** 8 h 23 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014 und #019 (Oberfläche). **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 |
|---|---| |---|---|
@@ -28,12 +28,19 @@
| #018 Zielbenutzer und sudoers | 0 h 30 min | | #018 Zielbenutzer und sudoers | 0 h 30 min |
| #019 Vorlagen | 0 h 20 min | | #019 Vorlagen | 0 h 20 min |
| #020 Host-zu-Host-Assistent | 0 h 50 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.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
--- ---
@@ -101,3 +108,21 @@
- **Bestätigung im neuen Tab:** Der Assistent hält seine Eingaben im Speicher; eine Umleitung nach `/bestaetigen` hätte sie gelöscht. - **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. - **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. - **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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+12 -5
View File
@@ -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.
+8 -4
View File
@@ -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.
+32 -5
View File
@@ -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).
+34 -4
View File
@@ -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.
+31 -4
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sshkv", "name": "sshkv",
"version": "0.3.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,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;
+36 -30
View File
@@ -274,44 +274,47 @@ model HostSchluessel {
// ---------------------------------------------------------------- Schluessel // ---------------------------------------------------------------- Schluessel
model Schluessel { model Schluessel {
id String @id @default(cuid()) id String @id @default(cuid())
/// Zweck, z. B. „Backup srv-backup01 → srv-pve01“. /// Zweck, z. B. „Backup srv-backup01 → srv-pve01“.
name String name String
/// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01. /// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01.
slug String @unique slug String @unique
typ SchluesselTyp typ SchluesselTyp
kategorie SchluesselKategorie kategorie SchluesselKategorie
status SchluesselStatus @default(AKTIV) status SchluesselStatus @default(AKTIV)
/// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar". /// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar".
publicKey String publicKey String
fingerprint String @unique fingerprint String @unique
kommentar String kommentar String
/// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts. /// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts.
privatVerschluesselt Bytes? privatVerschluesselt Bytes?
/// Data-Key, verschluesselt mit dem Master-Key. /// Data-Key, verschluesselt mit dem Master-Key.
dekVerschluesselt Bytes? dekVerschluesselt Bytes?
masterKeyVersion Int? masterKeyVersion Int?
privatGeloeschtAm DateTime? privatGeloeschtAm DateTime?
passphraseGeschuetzt Boolean @default(false) passphraseGeschuetzt Boolean @default(false)
/// Genau ein Deployment-Key ist Instanz-Standard (partieller Index). /// Genau ein Deployment-Key ist Instanz-Standard (partieller Index).
instanzStandard Boolean @default(false) instanzStandard Boolean @default(false)
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
vorlageId String? vorlageId String?
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull) vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
tags String[] tags String[]
ablaufAm DateTime? ablaufAm DateTime?
zuletztVerwendetAm DateTime? zuletztVerwendetAm DateTime?
/// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
widerrufEingeleitetAm DateTime?
/// Rotationskette: der Schluessel, den dieser abgeloest hat. /// Rotationskette: der Schluessel, den dieser abgeloest hat.
vorgaengerId String? @unique vorgaengerId String? @unique
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull) vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
nachfolger Schluessel? @relation("Rotation") nachfolger Schluessel? @relation("Rotation")
erstelltVonId String? erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull) erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service). /// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
selbstHinterlegt Boolean @default(false) selbstHinterlegt Boolean @default(false)
angelegtAm DateTime @default(now()) angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
publicVerwendungen PublicVerwendung[] publicVerwendungen PublicVerwendung[]
privateVerwendungen PrivateVerwendung[] privateVerwendungen PrivateVerwendung[]
@@ -333,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?
+274
View File
@@ -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);
});
+3
View File
@@ -39,6 +39,9 @@ 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" }, { name: "vorlagen", pfad: "/vorlagen" },
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019). // Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
{ {
+13
View File
@@ -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">
+3 -2
View File
@@ -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,7 +53,7 @@ export default async function Uebersicht() {
} }
aktionen={ aktionen={
<> <>
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button> <AlleAbgleichenKnopf />
<Button href="/assistent" icon={<Route className="size-4" />}> <Button href="/assistent" icon={<Route className="size-4" />}>
Host-zu-Host-Assistent Host-zu-Host-Assistent
</Button> </Button>
+179 -9
View File
@@ -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)) && (
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}> <>
Bearbeiten {verwalten && (
</Button> <Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
Bearbeiten
</Button>
)}
{rotierbar && (
<Button href={`/schluessel/${s.id}/rotieren`} icon={<RefreshCw className="size-4" />}>
{rotationLaeuft ? "Rotation fortsetzen" : "Rotieren"}
</Button>
)}
{widerrufbar && widerruf && (
<WiderrufKnopf uebersicht={widerruf} ruecksprung={`/schluessel/${s.id}`} />
)}
</>
) )
} }
/> />
{rotationDesVorgaengers && (
<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>
);
}
+54 -3
View File
@@ -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>
+118
View File
@@ -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>
);
}
+155 -9
View File
@@ -1,18 +1,164 @@
import { Download, Network, Search } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; import { Zuweisungsmatrix } from "@/components/zuweisungen/Zuweisungsmatrix";
import { Button, buttonKlassen } from "@/components/ui/Button";
import { Ankreuzfeld, Auswahlliste, Feld } from "@/components/ui/Feld";
import { Kennzahl } from "@/components/ui/Kennzahl";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { KATEGORIE_TEXT } from "@/lib/schluessel";
import { matrixFilterAus, matrixFilterQuery } from "@/lib/zuweisungsmatrix";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { zielAuswahl } from "@/server/queries/ziele";
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
export const metadata: Metadata = { title: "Zuweisungen" }; export const metadata: Metadata = { title: "Zuweisungen" };
export default async function Zuweisungen() { type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
/**
* Zuweisungsmatrix Schluessel × Host (#025). Filter per GET, damit Links und der CSV-Export
* denselben Ausschnitt zeigen. Die Matrix plant nur – ausgerollt wird ueber den Dry-Run.
*/
export default async function Zuweisungen({ searchParams }: { searchParams: Suchparameter }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich(); const { benutzer } = await sitzungErforderlich("lesen", "/zuweisungen");
const filter = matrixFilterAus(await searchParams);
const darfZuweisen = darf(benutzer.rolle, "publicKeyAusrollen");
const [{ matrix, gruppen, personen }, auswahl] = await Promise.all([
zuweisungsMatrix(filter),
darfZuweisen ? zielAuswahl() : Promise.resolve(undefined),
]);
const gefiltert = Boolean(
filter.gruppeId || filter.kategorie || filter.personId || filter.q || filter.nurBelegt,
);
const { zaehler } = matrix;
const leer = matrix.zeilen.length === 0 || matrix.spalten.length === 0;
return ( return (
<Platzhalterseite <div className="flex min-w-0 flex-col gap-6 sm:gap-7">
titel="Zuweisungen" <Seitenkopf
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt" titel="Zuweisungen"
phase={2} untertitel="Welcher Schlüssel auf welchem Host verwaltet ist"
issue="#016" aktionen={
/> <a
href={`/api/zuweisungen.csv${matrixFilterQuery(filter)}`}
download
className={buttonKlassen("sekundaer")}
>
<Download className="size-4" aria-hidden />
CSV exportieren
</a>
}
/>
<div className="grid grid-cols-2 gap-3 sm:gap-5 xl:grid-cols-4">
<Kennzahl titel="Schlüssel" wert={zaehler.schluessel} hinweis="aktiv, in der Ansicht" />
<Kennzahl titel="Hosts" wert={zaehler.hosts} hinweis="nicht archiviert" />
<Kennzahl titel="Zuweisungen" wert={zaehler.zuweisungen} hinweis="Gruppen je Host gezählt" />
<Kennzahl
titel="Nicht synchron"
wert={zaehler.nichtSynchron}
variante={zaehler.nichtSynchron > 0 ? "messing" : "neutral"}
hinweis="geplant, Drift, entfernen, Fehler"
/>
</div>
<form
method="get"
role="search"
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_10rem_11rem]"
>
<Feld
beschriftung="Suche"
name="q"
type="search"
defaultValue={filter.q}
placeholder="Schlüssel- oder Hostname"
autoComplete="off"
spellCheck={false}
className="sm:col-span-2 lg:col-span-1"
/>
<Auswahlliste beschriftung="Hostgruppe" name="gruppe" defaultValue={filter.gruppeId}>
<option value="">Alle Hosts</option>
{gruppen.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={filter.kategorie}>
<option value="">Alle</option>
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Person" name="person" defaultValue={filter.personId}>
<option value="">Alle Personen</option>
{personen.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Auswahlliste>
<div className="flex flex-col gap-3 sm:col-span-2 sm:flex-row sm:items-center sm:justify-between lg:col-span-4">
<Ankreuzfeld
name="belegt"
value="1"
defaultChecked={filter.nurBelegt}
beschriftung="Nur Zeilen und Spalten mit Zuweisungen"
className="-mx-2"
/>
<div className="flex gap-2.5">
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
Filtern
</Button>
{gefiltert && (
<Button href="/zuweisungen" className="flex-1 sm:flex-none">
Zurücksetzen
</Button>
)}
</div>
</div>
</form>
{leer ? (
gefiltert ? (
<Leerzustand
icon={<Search className="size-5" />}
titel="Keine Treffer"
text="Kein Schlüssel und kein Host passen zu Suche und Filter."
aktion={<Button href="/zuweisungen">Filter zurücksetzen</Button>}
/>
) : (
<Leerzustand
icon={<Network className="size-5" />}
titel="Noch nichts zuzuordnen"
text="Die Matrix braucht mindestens einen aktiven Schlüssel und einen Host."
/>
)
) : (
<section className="flex min-w-0 flex-col gap-3" aria-label="Matrix">
<p className="text-muted text-[13px]">
<span className="text-text font-medium">P</span> Public Key direkt ·{" "}
<span className="text-text font-medium">P·G</span> über Hostgruppe ·{" "}
<span className="text-text font-medium">K</span> Private Key · <span className="text-ok">●</span>{" "}
synchron · <span className="text-accent">○</span> geplant oder wird entfernt ·{" "}
<span className="text-danger">●</span> Drift oder Fehler
</p>
<Zuweisungsmatrix
zeilen={matrix.zeilen}
spalten={matrix.spalten}
zellen={Object.fromEntries(matrix.zellen)}
darfZuweisen={darfZuweisen}
darfPrivatEntfernen={darf(benutzer.rolle, "privateKeyAusrollen")}
{...(auswahl ? { auswahl } : {})}
/>
</section>
)}
</div>
); );
} }
+26
View File
@@ -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",
},
});
}
+139
View File
@@ -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>
</>
);
}
+4 -36
View File
@@ -1,12 +1,13 @@
"use client"; "use client";
import { ArrowLeft, ArrowRight, Check, ExternalLink, Play } from "lucide-react"; import { ArrowLeft, ArrowRight, ExternalLink, Play } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { clsx } from "clsx";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui"; import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
import { Auswahlliste, Textbereich } from "@/components/ui/Feld"; import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf"; import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { warten } from "@/components/vorgang/warten";
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen"; import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
import { sudoersInhalt } from "@/lib/ssh/zielkonto"; import { sudoersInhalt } from "@/lib/ssh/zielkonto";
import { import {
@@ -91,19 +92,6 @@ function kurz(text: string): string {
); );
} }
/** Wartet auf das Ende eines Vorgangs (fuer die Kette im Klick, nicht als Hook). */
async function warten(
vorgangId: string,
): Promise<{ status: string; fehler: string | null; ergebnis: unknown }> {
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 { status: string; fehler: string | null; ergebnis: unknown };
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
await new Promise((weiter) => setTimeout(weiter, 800));
}
}
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) { export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
const [schritt, setSchritt] = useState(0); const [schritt, setSchritt] = useState(0);
const [w, setW] = useState<Werte>(LEER); const [w, setW] = useState<Werte>(LEER);
@@ -249,27 +237,7 @@ export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestae
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<ol className="flex flex-wrap gap-2" aria-label="Schritte"> <Schrittleiste schritte={SCHRITTE} aktuell={schritt} />
{SCHRITTE.map((titel, index) => (
<li
key={titel}
aria-current={index === schritt ? "step" : undefined}
className={clsx(
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
index === schritt
? "border-accent text-accent bg-raised font-medium"
: index < schritt
? "border-border text-text"
: "border-border text-muted",
)}
>
<span className="font-mono">
{index < schritt ? <Check className="size-4" aria-hidden /> : index + 1}
</span>
{titel}
</li>
))}
</ol>
{schritt === 0 && ( {schritt === 0 && (
<Karte <Karte
+29 -6
View File
@@ -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(
+241
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+15
View File
@@ -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,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>
);
}
+224
View File
@@ -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>
);
}
+64
View File
@@ -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>
</>
);
}
+189
View File
@@ -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>
);
}
+37
View File
@@ -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";
}
+37
View File
@@ -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;
}
+3
View File
@@ -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. */
+165
View File
@@ -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 };
}
+18 -3
View File
@@ -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"}.`,
+311
View File
@@ -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);
}
+58
View File
@@ -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 {};
}
+71
View File
@@ -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);
}
}
+8 -2
View File
@@ -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,7 +88,9 @@ 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) }
: { hostId: id.parse(auswahl.hostId) }, : "schluesselIds" in auswahl
? { schluesselIds: z.array(id).min(1).max(200).parse(auswahl.schluesselIds) }
: { 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.
darf(benutzer.rolle, "privateKeyAusrollen"), darf(benutzer.rolle, "privateKeyAusrollen"),
+85
View File
@@ -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);
}
}
+46
View File
@@ -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>>;
+67 -1
View File
@@ -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;
}
+121
View File
@@ -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,
};
}
+69
View File
@@ -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 };
}
+32
View File
@@ -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 });
}
+313
View File
@@ -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);
}
+6 -1
View File
@@ -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). */
+105
View File
@@ -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 };
}
+42 -2
View File
@@ -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).
+433
View File
@@ -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;
}
+314
View File
@@ -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;
}
};
+2 -2
View File
@@ -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.`,
+6 -3
View File
@@ -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({
+4
View File
@@ -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 } };
+3 -1
View File
@@ -1,4 +1,5 @@
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";
@@ -8,7 +9,7 @@ import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
import { zielkontoAnlegenHandler, zielkontoEntfernenHandler } from "@/worker/ablaeufe/zielkonto"; 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,
@@ -21,6 +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_ANLEGEN: zielkontoAnlegenHandler,
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler, ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
}; };
+18 -2
View File
@@ -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 …`);
+74
View File
@@ -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();
});
+90
View File
@@ -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();
}
+71
View File
@@ -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);
});
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "@playwright/test";
import {
anmelden,
archivieren,
aufZiel,
hostEinrichten,
kernAufbauen,
PORT,
zieleZuruecksetzen,
} from "./hilfen";
/**
* Widerruf im Browser (#024), gebauter Stand + Worker: Ausgangslage ist der Kern-Anwendungsfall
* (Private Key auf A, Public Key auf B). „Widerrufen“ → Dialog → Dry-Run → anwenden → Status
* „widerrufen“; danach kommt A nicht mehr nach B.
*/
test.setTimeout(240_000);
test.beforeAll(() => zieleZuruecksetzen());
test("Widerruf mit einem Klick", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
const zweck = `zz-e2e Widerruf ${kennung}`;
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
await kernAufbauen(page, zweck, hostA, hostB);
await page.reload();
// Dialog: zeigt genau, was passiert.
await page.getByRole("button", { name: "Widerrufen" }).click();
const dialog = page.getByRole("dialog", { name: `„${zweck}“ widerrufen?` });
await expect(dialog.getByText(`root@${hostB}`)).toBeVisible();
await expect(dialog.getByText(`root@${hostA}:~/.ssh/`)).toBeVisible();
await expect(dialog.getByText(/config-Eintrag „Host zu-b“/)).toBeVisible();
await page.screenshot({ path: "docs/anleitung/bilder/widerruf-desktop.png" });
await dialog.getByRole("button", { name: "Widerruf vorbereiten" }).click();
// Zwischenzustand: noch nicht widerrufen, nichts mehr zuweisbar.
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible();
await expect(page.getByText("Widerruf unvollständig")).toBeVisible();
await expect(page.getByRole("button", { name: "Ausrollen" })).toHaveCount(0);
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: "docs/anleitung/bilder/widerruf-dry-run-desktop.png", fullPage: true });
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
await expect(page.getByRole("heading", { name: "Widerrufen", exact: true })).toBeVisible({
timeout: 60_000,
});
await expect(page.getByText("Widerruf unvollständig")).toHaveCount(0);
expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toContain("id_ed25519");
expect(() => aufZiel("ziel-a", "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b true")).toThrow();
await archivieren(page, hostA);
await archivieren(page, hostB);
});
+147
View File
@@ -0,0 +1,147 @@
import { randomUUID } from "node:crypto";
import { expect, test, type Page } from "@playwright/test";
import pg from "pg";
import "dotenv/config";
/**
* Zuweisungsmatrix (#025) im gebauten Stand. Kein Worker und kein SSH-Ziel noetig: die Matrix
* zeigt und plant nur. Schluessel, Hosts und Hostgruppe legt der Test direkt in der
* Entwicklungsdatenbank an (Praefix zz-e2e) und raeumt sie am Ende wieder weg.
*/
const name = `zz-e2e-mx${Date.now().toString(36)}`;
const ids = {
schluessel: randomUUID(),
gruppe: randomUUID(),
h1: randomUUID(),
h2: randomUUID(),
h3: randomUUID(),
};
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
await client.connect();
try {
return await arbeit(client);
} finally {
await client.end();
}
}
async function anmelden(page: Page, rolle: "admin" | "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.beforeAll(async () => {
await datenbank(async (client) => {
for (const [id, nr] of [
[ids.h1, 1],
[ids.h2, 2],
[ids.h3, 3],
] as const) {
await client.query(`INSERT INTO "Host" ("id","name","adresse","geaendertAm") VALUES ($1,$2,$3,now())`, [
id,
`${name}-h${nr}`,
`10.99.0.${nr}`,
]);
}
await client.query(`INSERT INTO "Hostgruppe" ("id","name") VALUES ($1,$2)`, [ids.gruppe, `${name}-g`]);
await client.query(`INSERT INTO "HostgruppeMitglied" ("hostId","hostgruppeId") VALUES ($1,$3),($2,$3)`, [
ids.h1,
ids.h2,
ids.gruppe,
]);
await client.query(
`INSERT INTO "Schluessel" ("id","name","slug","typ","kategorie","publicKey","fingerprint","kommentar","geaendertAm")
VALUES ($1,$2,$3,'ED25519','DIENST','ssh-ed25519 AAAA zz-e2e',$4,'zz-e2e',now())`,
[ids.schluessel, `${name} Backup`, name, `SHA256:${name}`],
);
await client.query(
`INSERT INTO "PublicVerwendung" ("id","schluesselId","hostgruppeId","zielbenutzer","optionen")
VALUES ($1,$2,$3,'root','restrict')`,
[randomUUID(), ids.schluessel, ids.gruppe],
);
});
});
test.afterAll(async () => {
await datenbank(async (client) => {
// Verwendungen und Mitgliedschaften haengen per Cascade daran.
await client.query(`DELETE FROM "Schluessel" WHERE "id" = $1`, [ids.schluessel]);
await client.query(`DELETE FROM "Hostgruppe" WHERE "id" = $1`, [ids.gruppe]);
await client.query(`DELETE FROM "Host" WHERE "id" = ANY($1)`, [[ids.h1, ids.h2, ids.h3]]);
});
});
test("Matrix zeigt Gruppen-Zuweisung, filtert, weist zu und exportiert", async ({ page }) => {
await anmelden(page, "admin");
// Die Suche trifft Schluessel und Hosts dieses Tests – fremde Daten bleiben draussen.
await page.goto(`/zuweisungen?q=${name}`);
const matrix = page.locator("table");
await expect(matrix.getByRole("columnheader", { name: `${name}-h3` })).toBeVisible();
// Gruppen-Zuweisung erscheint auf beiden Mitgliedern als „P·G“ mit Zustand.
for (const nr of [1, 2]) {
const zelle = matrix.getByRole("button", {
name: `${name} Backup auf ${name}-h${nr}: Public Key über Hostgruppe ${name}-g, Benutzer root, noch nicht ausgerollt`,
});
await expect(zelle).toBeVisible();
await expect(zelle).toContainText("P·G");
}
// Filter Hostgruppe: der dritte Host gehoert nicht dazu und verschwindet.
await page.getByLabel("Hostgruppe").selectOption({ label: `${name}-g` });
await page.getByRole("button", { name: "Filtern" }).click();
await expect(page).toHaveURL(/gruppe=/);
await expect(matrix.getByRole("columnheader", { name: `${name}-h1` })).toBeVisible();
await expect(matrix.getByRole("columnheader", { name: `${name}-h3` })).toHaveCount(0);
// Leere Zelle auf h3 → Dialog mit vorausgewaehltem Host → zuweisen.
await page.goto(`/zuweisungen?q=${name}`);
await matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: nicht zugewiesen`) }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByLabel("Ziel", { exact: true })).toHaveValue(`host:${ids.h3}`);
await dialog.getByLabel("Zielbenutzer").fill("deploy");
await dialog.getByRole("button", { name: "Zuweisen" }).click();
await expect(dialog.getByText("Public Key direkt")).toBeVisible();
await expect(dialog.getByRole("link", { name: "Zum Dry-Run" })).toBeVisible();
const angelegt = await datenbank((client) =>
client.query(
`SELECT "zielbenutzer" FROM "PublicVerwendung" WHERE "schluesselId" = $1 AND "hostId" = $2`,
[ids.schluessel, ids.h3],
),
);
expect(angelegt.rows).toEqual([{ zielbenutzer: "deploy" }]);
await dialog.getByRole("button", { name: "Schließen" }).click();
await expect(
matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: Public Key direkt`) }),
).toBeVisible();
// CSV mit derselben Sitzung: Gruppen-Zeile je Mitglied und die neue direkte Zuweisung.
const csv = await page.request.get(`/api/zuweisungen.csv?q=${name}`);
expect(csv.status()).toBe(200);
const text = await csv.text();
expect(text).toContain(
`"${name} Backup";"SHA256:${name}";"${name}-h2";"root";"Public Key";"${name}-g";"restrict";"noch nicht ausgerollt"`,
);
expect(text).toContain(`"${name}-h3";"deploy";"Public Key";""`);
});
test("Leser sieht die Matrix, aber keinen Zuweisen-Knopf", async ({ page }) => {
await anmelden(page, "reader");
await page.goto(`/zuweisungen?q=${name}`);
const matrix = page.locator("table");
await matrix
.getByRole("button", { name: new RegExp(`auf ${name}-h1: Public Key über Hostgruppe`) })
.click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Public Key über Hostgruppe")).toBeVisible();
await expect(dialog.getByRole("button", { name: "Zuweisen" })).toHaveCount(0);
await expect(dialog.getByRole("button", { name: "Zuweisung entfernen" })).toHaveCount(0);
await dialog.getByRole("button", { name: "Schließen" }).click();
// Leere Zellen sind fuer Leser keine Knoepfe.
await expect(matrix.getByRole("button", { name: /nicht zugewiesen/ })).toHaveCount(0);
});
+169
View File
@@ -0,0 +1,169 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { schluesselpaarErzeugen } from "@/lib/ssh/openssh";
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
import { keyUebernehmen } from "@/server/services/uebernahme";
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
import { datenbankLeeren } from "../hilfen/db";
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
import { befehlAlsRoot, zielA, zielBereit } from "./hilfen/ziele";
/** Abgleich und Drift-Erkennung gegen ein echtes sshd (#021). */
const AKTEUR = { id: "test", name: "Test" };
let aufraeumen: () => void;
beforeAll(async () => {
await zielBereit(zielA);
aufraeumen = masterKeyAnlegen();
});
afterAll(() => aufraeumen());
beforeEach(async () => {
await datenbankLeeren();
await deploymentKeySicherstellen();
await befehlAlsRoot(
zielA,
"userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv; rm -rf /root/.ssh; true",
);
});
async function ausgerollterHost() {
const host = await eingerichteterHost(zielA, "ziel-a");
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: "sshkv:abgleich" });
const schluessel = await prisma.schluessel.create({
data: {
name: "Abgleich",
slug: "abgleich",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:abgleich",
},
});
await publicVerwendungAnlegen(
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root" },
AKTEUR,
);
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
eingabe: { ziele: await zieleVonVerwendungen({ schluesselId: schluessel.id }) },
});
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
return { host, schluessel };
}
const abgleichen = (hostId: string) => vorgangLaufen("ABGLEICH", { hostId });
// Postgres sortiert Enums nach Deklaration; fuer lesbare Erwartungen alphabetisch in JS.
const offen = async (hostId: string) =>
(await prisma.abweichung.findMany({ where: { hostId, behobenAm: null } })).sort((a, b) =>
a.art.localeCompare(b.art),
);
describe("Abgleich", () => {
it("ist nach dem Ausrollen synchron und haelt den Ist-Zustand je Benutzer fest", async () => {
const { host } = await ausgerollterHost();
const vorgang = await abgleichen(host.id);
expect(vorgang.fehler).toBeNull();
expect(await offen(host.id)).toEqual([]);
const aktuell = await prisma.host.findUniqueOrThrow({ where: { id: host.id } });
expect(aktuell.driftZustand).toBe("SYNCHRON");
expect(aktuell.letzterAbgleichAm).not.toBeNull();
const beobachtet = await prisma.beobachtung.findMany({
where: { hostId: host.id },
orderBy: { zielbenutzer: "asc" },
});
expect(beobachtet.map((b) => b.zielbenutzer)).toEqual(["root", "sshkv"]);
expect(JSON.stringify(beobachtet[0]?.eintraege)).toContain('"herkunft":"verwaltet"');
});
it("findet fremde Keys, fehlende Keys und falsche Rechte – und schliesst behobene Befunde", async () => {
const { host } = await ausgerollterHost();
const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: "fremd@laptop" });
await befehlAlsRoot(
zielA,
`echo '${fremd.publicKeyZeile}' >> /root/.ssh/authorized_keys && sed -i '/sshkv:abgleich/d' /root/.ssh/authorized_keys && chmod 777 /root/.ssh`,
);
await abgleichen(host.id);
const arten = (await offen(host.id)).map((a) => a.art);
expect(arten).toEqual(["FEHLENDER_SCHLUESSEL", "RECHTE", "UNBEKANNTER_SCHLUESSEL"]);
expect((await prisma.host.findUniqueOrThrow({ where: { id: host.id } })).driftZustand).toBe("DRIFT");
expect((await prisma.publicVerwendung.findFirstOrThrow()).zustand).toBe("ABWEICHUNG");
// Zweiter Lauf ohne Aenderung: keine doppelten Befunde.
await abgleichen(host.id);
expect(await offen(host.id)).toHaveLength(3);
// Von Hand bereinigen: fremden Key raus, Rechte zurueck – der fehlende Key bleibt Befund.
await befehlAlsRoot(zielA, "sed -i '/fremd@laptop/d' /root/.ssh/authorized_keys && chmod 700 /root/.ssh");
await abgleichen(host.id);
expect((await offen(host.id)).map((a) => a.art)).toEqual(["FEHLENDER_SCHLUESSEL"]);
});
it("bemerkt einen veraenderten Private Key", async () => {
const host = await eingerichteterHost(zielA, "ziel-a");
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:privat");
const schluessel = await prisma.schluessel.create({
data: {
id: neu.id,
name: "Privat",
slug: "privat",
typ: "ED25519",
kategorie: "DIENST",
publicKey: neu.publicKeyZeile,
fingerprint: neu.fingerprint,
kommentar: "sshkv:privat",
...neu.versiegelt,
},
});
await privateVerwendungAnlegen(
{ schluesselId: schluessel.id, hostId: host.id, zielbenutzer: "root", pfad: "~/.ssh/id_privat" },
AKTEUR,
);
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
eingabe: { ziele: await zieleVonPrivatVerwendungen({ schluesselId: schluessel.id }) },
});
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
await abgleichen(host.id);
expect(await offen(host.id)).toEqual([]);
await befehlAlsRoot(zielA, "echo kaputt > /root/.ssh/id_privat");
await abgleichen(host.id);
expect((await offen(host.id)).map((a) => a.art)).toEqual(["PRIVATE_KEY_FEHLT"]);
expect((await prisma.privateVerwendung.findFirstOrThrow()).zustand).toBe("ABWEICHUNG");
});
it("uebernimmt einen fremden Key in den verwalteten Block (#022)", async () => {
const { host } = await ausgerollterHost();
const fremd = await schluesselpaarErzeugen("ED25519", { kommentar: "anna@laptop" });
await befehlAlsRoot(zielA, `sed -i '1i no-pty ${fremd.publicKeyZeile}' /root/.ssh/authorized_keys`);
await abgleichen(host.id);
expect((await offen(host.id)).map((a) => a.art)).toEqual(["UNBEKANNTER_SCHLUESSEL"]);
const admin = await prisma.benutzer.create({ data: { id: "test", name: "Test", rolle: "ADMIN" } });
const { schluesselId } = await keyUebernehmen(
{
hostId: host.id,
zielbenutzer: "root",
fingerprint: fremd.fingerprint,
name: "Laptop Anna",
kategorie: "PERSON",
personId: null,
verwalten: true,
},
{ id: admin.id, name: admin.name },
);
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
eingabe: { ziele: await zieleVonVerwendungen({ schluesselId }) },
});
await vorgangLaufen("AUSROLLEN", { eingabe: { trockenlaufId: trockenlauf.id } });
const datei = (await befehlAlsRoot(zielA, "cat /root/.ssh/authorized_keys")).stdout;
expect(datei.startsWith("# BEGIN SSHKV MANAGED")).toBe(true);
expect(datei.match(/anna@laptop/g)).toHaveLength(1);
expect(datei).toContain(`no-pty ${fremd.publicKeyZeile}`);
await abgleichen(host.id);
expect(await offen(host.id)).toEqual([]);
});
});
+2 -1
View File
@@ -28,7 +28,8 @@ export async function vorgangLaufen(
| "AUSROLLEN" | "AUSROLLEN"
| "VERBINDUNGSTEST" | "VERBINDUNGSTEST"
| "ZIELKONTO_ANLEGEN" | "ZIELKONTO_ANLEGEN"
| "ZIELKONTO_ENTFERNEN", | "ZIELKONTO_ENTFERNEN"
| "ABGLEICH",
daten: { daten: {
hostId?: string; hostId?: string;
eingabe?: object; eingabe?: object;
+126
View File
@@ -0,0 +1,126 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { rotationsDatum } from "@/lib/rotation";
import {
rotationAltWiderrufen,
rotationStarten,
rotationUebernehmen,
rotationsStand,
} from "@/server/services/rotation";
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
import { widerrufAbschliessen } from "@/server/services/widerruf";
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
import { datenbankLeeren } from "../hilfen/db";
import { vorgangImWorker, warteschlangeStoppen } from "../hilfen/vorgang";
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
/**
* Rotation gegen echte Hosts (#023): der neue Schluessel funktioniert (Verbindungstest gruen),
* der alte ist danach von beiden Hosts verschwunden, und `ssh <alias>` von A geht weiter –
* derselbe Alias zeigt jetzt auf die neue Datei.
*/
const AKTEUR = { id: "", name: "Test-Admin" };
let aufraeumen: () => void;
beforeAll(async () => {
await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
aufraeumen = masterKeyAnlegen();
});
afterAll(async () => {
aufraeumen();
await warteschlangeStoppen();
});
beforeEach(async () => {
await datenbankLeeren();
await deploymentKeySicherstellen();
const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
});
async function trockenlaufUndAusrollen(schluesselId: string) {
const ziele = [
...(await zieleVonVerwendungen({ schluesselId })),
...(await zieleVonPrivatVerwendungen({ schluesselId })),
];
const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele }, angefordertVonId: AKTEUR.id });
expect(trockenlauf.status).toBe("ERLEDIGT");
const ausrollen = await vorgangLaufen("AUSROLLEN", {
eingabe: { trockenlaufId: trockenlauf.id },
angefordertVonId: AKTEUR.id,
});
expect(ausrollen.fehler).toBeNull();
}
describe("Rotation", () => {
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
const alt = await prisma.schluessel.create({
data: {
id: paar.id,
name: "Backup",
slug: "backup",
typ: "ED25519",
kategorie: "DIENST",
publicKey: paar.publicKeyZeile,
fingerprint: paar.fingerprint,
kommentar: "sshkv:backup",
...paar.versiegelt,
},
select: { id: true, publicKey: true },
});
await privateVerwendungAnlegen(
{
schluesselId: alt.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_backup",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
knownHostsFuer: [b.id],
},
AKTEUR,
);
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR);
await trockenlaufUndAusrollen(alt.id);
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true });
const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: alt.id },
select: { id: true, publicKey: true },
});
// 3: ausrollen – beide Schluessel liegen jetzt nebeneinander, der Alias zeigt auf den neuen.
await trockenlaufUndAusrollen(neu.id);
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
expect(config).toContain(`IdentityFile ~/.ssh/${neuerPfad}`);
// 4: Verbindungstest mit dem neuen Schluessel.
const verwendung = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neu.id } });
const test = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } });
expect(test.fehler).toBeNull();
expect((await rotationsStand(alt.id)).schritt).toBe(4);
// 5: alten widerrufen.
await rotationAltWiderrufen(alt.id, AKTEUR, { privatErlaubt: true });
await trockenlaufUndAusrollen(alt.id);
expect((await widerrufAbschliessen([alt.id], AKTEUR))[0]?.status).toBe("ROTIERT");
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
expect(authorized).not.toContain(alt.publicKey.split(" ")[1]!);
expect(authorized).toContain(neu.publicKey.split(" ")[1]!);
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
expect(dateien).not.toContain("id_backup");
expect(dateien).toContain(neuerPfad);
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(ssh.stdout.trim()).toBe("root");
});
});
+171
View File
@@ -0,0 +1,171 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
import {
widerrufAbschliessen,
widerrufEinleiten,
widerrufZielkontenEntfernen,
} from "@/server/services/widerruf";
import type { Plan } from "@/worker/ablaeufe/ausrollen";
import { vorgangAusfuehren } from "@/worker/ausfuehren";
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
import { HANDLER } from "@/worker/handler";
import { datenbankLeeren } from "../hilfen/db";
import { warteschlangeStoppen } from "../hilfen/vorgang";
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
/**
* Widerruf gegen echte Hosts (#024): Public Key aus authorized_keys von B, Private Key samt
* config- und known_hosts-Eintrag von A, optional das Zielkonto deploy auf B. Danach scheitert
* `ssh <alias>` von A. Scheitert ein Host, bleibt der Schluessel im Zwischenzustand.
*/
const AKTEUR = { id: "", name: "Test-Admin" };
let aufraeumen: () => void;
beforeAll(async () => {
await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
aufraeumen = masterKeyAnlegen();
});
afterAll(async () => {
aufraeumen();
await warteschlangeStoppen();
});
beforeEach(async () => {
await datenbankLeeren();
await deploymentKeySicherstellen();
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";
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
const admin = await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } });
AKTEUR.id = admin.id;
});
async function dienstSchluessel(name: string) {
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", `sshkv:${name}`);
return prisma.schluessel.create({
data: {
id: neu.id,
name,
slug: name,
typ: "ED25519",
kategorie: "DIENST",
publicKey: neu.publicKeyZeile,
fingerprint: neu.fingerprint,
kommentar: `sshkv:${name}`,
...neu.versiegelt,
},
select: { id: true, fingerprint: true, publicKey: true },
});
}
async function trockenlaufUndAusrollen(schluesselId: string) {
const ziele = [
...(await zieleVonVerwendungen({ schluesselId })),
...(await zieleVonPrivatVerwendungen({ schluesselId })),
];
const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele }, angefordertVonId: AKTEUR.id });
expect(trockenlauf.status).toBe("ERLEDIGT");
const ausrollen = await vorgangLaufen("AUSROLLEN", {
eingabe: { trockenlaufId: trockenlauf.id },
angefordertVonId: AKTEUR.id,
});
return { plan: trockenlauf.plan as unknown as Plan, ausrollen };
}
/** Deployment-Zugang wie im Assistenten: deploy auf B, Private Key auf A, Alias zu-b. */
async function deploymentZugang() {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const konto = await vorgangLaufen("ZIELKONTO_ANLEGEN", {
hostId: b.id,
eingabe: { benutzer: "deploy", modus: "KEINER", befehle: [] },
});
expect(konto.fehler).toBeNull();
const schluessel = await dienstSchluessel("deploy-a-b");
await privateVerwendungAnlegen(
{
schluesselId: schluessel.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_ed25519_deploy",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "deploy", port: 22 },
knownHostsFuer: [b.id],
},
AKTEUR,
);
await publicVerwendungAnlegen(
{ schluesselId: schluessel.id, hostId: b.id, zielbenutzer: "deploy" },
AKTEUR,
);
const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(ausrollen.fehler).toBeNull();
const vorher = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(vorher.stdout.trim()).toBe("deploy");
return { a, b, schluessel };
}
describe("Widerruf", () => {
it("entfernt Public Key, Private Key, config, known_hosts und auf Wunsch das Zielkonto", async () => {
const { schluessel } = await deploymentZugang();
const blob = schluessel.publicKey.split(" ")[1]!;
await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true });
const { plan, ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(plan.ziele.every((z) => !z.fehler)).toBe(true);
expect(ausrollen.fehler).toBeNull();
expect((await befehlAlsRoot(zielB, "cat /home/deploy/.ssh/authorized_keys")).stdout).not.toContain(blob);
const ssh = await befehlAlsRoot(zielA, "ls -a /root/.ssh; cat /root/.ssh/config /root/.ssh/known_hosts");
expect(ssh.stdout).not.toContain("id_ed25519_deploy");
expect(ssh.stdout).not.toContain("zu-b");
expect(ssh.stdout).not.toContain("ssh-ed25519");
const danach = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b id -un");
expect(danach.code).not.toBe(0);
// Zielkonto: erst nach den Schluesseln, dann Abschluss.
const konto = await prisma.zielkonto.findFirstOrThrow({ where: { benutzername: "deploy" } });
const vorgaenge = await widerrufZielkontenEntfernen([schluessel.id], [konto.id], AKTEUR);
for (const v of vorgaenge) await vorgangAusfuehren(v.id, HANDLER);
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgaenge[0]!.id } })).fehler).toBeNull();
expect((await befehlAlsRoot(zielB, "id deploy")).code).not.toBe(0);
const [ergebnis] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(ergebnis?.status).toBe("WIDERRUFEN");
const protokoll = await prisma.protokoll.findMany({ where: { fingerprint: schluessel.fingerprint } });
expect(protokoll.map((p) => p.aktion)).toEqual(
expect.arrayContaining(["schluessel.widerruf.eingeleitet", "schluessel.widerrufen"]),
);
expect(JSON.stringify(protokoll.map((p) => [p.details, p.diff]))).not.toContain("PRIVATE KEY");
});
it("bleibt im Zwischenzustand, wenn ein Host nicht erreichbar ist", async () => {
const { b, schluessel } = await deploymentZugang();
await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true });
// B „faellt aus“: ein Port, auf dem niemand lauscht.
await prisma.host.update({ where: { id: b.id }, data: { port: 1 } });
const { plan } = await trockenlaufUndAusrollen(schluessel.id);
expect(plan.ziele.find((z) => z.hostName === "ziel-b")?.fehler).toBeTruthy();
expect(plan.ziele.find((z) => z.hostName === "ziel-a")?.fehler).toBeUndefined();
const [offen] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(offen?.status).toBe("OFFEN");
expect(offen?.offen.map((o) => o.ziel)).toEqual(["deploy@ziel-b:~/.ssh/authorized_keys"]);
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: schluessel.id } })).status).toBe(
"AKTIV",
);
// Was ging, ist trotzdem weg: der Private Key auf A.
expect((await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout).not.toContain("id_ed25519_deploy");
// B ist zurueck: ein neuer Dry-Run erledigt den Rest.
await prisma.host.update({ where: { id: b.id }, data: { port: zielB.port } });
const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(ausrollen.fehler).toBeNull();
const [fertig] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(fertig?.status).toBe("WIDERRUFEN");
});
});
+260
View File
@@ -0,0 +1,260 @@
import { randomBytes } from "node:crypto";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
import { rotationsKette } from "@/server/queries/schluessel";
import {
RotationsFehler,
rotationAltWiderrufen,
rotationGesperrt,
rotationStarten,
rotationUebernehmen,
rotationsStand,
} from "@/server/services/rotation";
import { widerrufAbschliessen } from "@/server/services/widerruf";
import { masterKeyVergessen } from "@/worker/geheimnisse";
import { datenbankLeeren } from "./hilfen/db";
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
import { ed25519Blob, zeile } from "./ssh/hilfen";
/** Gefuehrte Rotation (#023): Regeln, Schritte, Rechte, Protokoll. */
let verzeichnis: string;
let admin: { id: string; name: string };
let a: { id: string };
let b: { id: string };
beforeAll(() => {
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
const datei = path.join(verzeichnis, "master.key");
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
masterKeyVergessen();
});
afterAll(async () => {
rmSync(verzeichnis, { recursive: true, force: true });
await warteschlangeStoppen();
});
beforeEach(async () => {
await datenbankLeeren();
admin = await prisma.benutzer.create({
data: { name: "Ada Admin", rolle: "ADMIN" },
select: { id: true, name: true },
});
a = await prisma.host.create({ data: { name: "srv-a", adresse: "10.0.0.1" }, select: { id: true } });
b = await prisma.host.create({ data: { name: "srv-b", adresse: "10.0.0.2" }, select: { id: true } });
});
/** Ein ausgerollter Dienst-Key wie nach dem Assistenten: Private auf A (Alias), Public auf B. */
async function alterSchluessel() {
const alt = await prisma.schluessel.create({
data: {
name: "Backup A → B",
slug: "backup-a-b",
typ: "ED25519",
kategorie: "DIENST",
publicKey: zeile("ssh-ed25519", ed25519Blob(), "alt"),
fingerprint: "SHA256:alt",
kommentar: "alt",
tags: ["backup"],
ablaufAm: new Date("2020-01-01T00:00:00Z"),
},
select: { id: true },
});
const jetzt = new Date();
await prisma.publicVerwendung.create({
data: {
schluesselId: alt.id,
hostId: b.id,
zielbenutzer: "backup",
optionen: 'from="10.0.0.1"',
zustand: "AUSGEROLLT",
zuletztAngewendetAm: jetzt,
},
});
await prisma.privateVerwendung.create({
data: {
schluesselId: alt.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_backup",
sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 },
knownHostsFuer: [b.id],
beimWiderruf: "UMBENENNEN",
zustand: "AUSGEROLLT",
zuletztAngewendetAm: jetzt,
},
});
return alt.id;
}
async function nachfolgerErzeugen(altId: string): Promise<string> {
const vorgangId = await rotationStarten(altId, admin);
await vorgangImWorker(vorgangId);
const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: altId },
select: { id: true },
});
return neu.id;
}
describe("Namen und Pfade", () => {
it("ersetzt ein frueheres Rotations-Suffix", () => {
expect(rotationsName("Backup", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)");
expect(rotationsName("Backup (rotiert 01.01.2026)", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)");
expect(rotationsName("x".repeat(200), "12.10.2026", 120)).toHaveLength(120);
});
it("haengt -rJJJJMMTT an und weicht belegten Pfaden aus", () => {
expect(rotationsPfad("~/.ssh/id_backup", "20261012", new Set())).toBe("~/.ssh/id_backup-r20261012");
expect(rotationsPfad("~/.ssh/id_backup-r20260101", "20261012", new Set())).toBe(
"~/.ssh/id_backup-r20261012",
);
expect(
rotationsPfad("~/.ssh/id_backup-r20261012", "20261012", new Set(["~/.ssh/id_backup-r20261012"])),
).toBe("~/.ssh/id_backup-r20261012-2");
});
it("rechnet das Datum in Berlin", () => {
expect(rotationsDatum(new Date("2026-10-11T23:30:00Z"))).toEqual({
anzeige: "12.10.2026",
kompakt: "20261012",
});
});
});
describe("Was sich rotieren laesst", () => {
const basis = {
status: "AKTIV" as const,
masterKeyVersion: 1,
widerrufEingeleitetAm: null,
privatZuweisungen: 0,
};
it("verlangt bei Personen-Keys einen neuen Public Key von aussen", () => {
expect(rotationGesperrt({ ...basis, kategorie: "PERSON", masterKeyVersion: null })).toMatchObject({
neuerPublicKeyNoetig: true,
});
expect(rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null })).toMatchObject({
neuerPublicKeyNoetig: true,
});
// Private Key aus sshkv geloescht, aber ausgerollt: sshkv erzeugt und verteilt den neuen selbst.
expect(
rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null, privatZuweisungen: 1 }),
).toBeNull();
expect(rotationGesperrt({ ...basis, kategorie: "DEPLOYMENT" })?.grund).toMatch(/eigene Rotation/);
expect(rotationGesperrt({ ...basis, kategorie: "DIENST", status: "WIDERRUFEN" })).not.toBeNull();
});
});
describe("Rotation Schritt fuer Schritt", () => {
it("fuehrt vom neuen Schluessel bis ROTIERT und haelt die Reihenfolge ein", async () => {
const altId = await alterSchluessel();
expect((await rotationsStand(altId)).schritt).toBe(0);
const neuId = await nachfolgerErzeugen(altId);
const neu = await prisma.schluessel.findUniqueOrThrow({
where: { id: neuId },
select: { name: true, typ: true, tags: true, ablaufAm: true, kategorie: true },
});
expect(neu.name).toBe(`Backup A → B (rotiert ${rotationsDatum().anzeige})`);
expect(neu).toMatchObject({ typ: "ED25519", kategorie: "DIENST", tags: ["backup"], ablaufAm: null });
expect((await rotationsStand(altId)).schritt).toBe(1);
await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/);
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: false })).rejects.toBeInstanceOf(
RotationsFehler,
);
expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({
public: 1,
privat: 1,
});
expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({
public: 0,
privat: 0,
});
const oeffentlich = await prisma.publicVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } });
expect(oeffentlich).toMatchObject({ hostId: b.id, zielbenutzer: "backup", optionen: 'from="10.0.0.1"' });
const privat = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } });
expect(privat).toMatchObject({
hostId: a.id,
zielbenutzer: "root",
pfad: `~/.ssh/id_backup-r${rotationsDatum().kompakt}`,
sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 },
knownHostsFuer: [b.id],
beimWiderruf: "UMBENENNEN",
});
expect((await rotationsStand(altId)).schritt).toBe(2);
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
/noch nicht überall/,
);
// Ausgerollt, aber noch nicht getestet.
const jetzt = new Date();
await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT" },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: neuId },
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
});
expect((await rotationsStand(altId)).schritt).toBe(3);
await prisma.vorgang.create({
data: { art: "VERBINDUNGSTEST", status: "FEHLGESCHLAGEN", eingabe: { privatVerwendungId: privat.id } },
});
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/grün/);
await prisma.vorgang.create({
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
});
expect((await rotationsStand(altId)).schritt).toBe(4);
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
expect(
await prisma.publicVerwendung.count({ where: { schluesselId: altId, zustand: "ENTFERNEN" } }),
).toBe(1);
// Der Nachfolger bleibt unberuehrt.
expect(
await prisma.publicVerwendung.count({ where: { schluesselId: neuId, zustand: "AUSGEROLLT" } }),
).toBe(1);
await prisma.publicVerwendung.updateMany({
where: { schluesselId: altId },
data: { zustand: "ENTFERNT" },
});
await prisma.privateVerwendung.updateMany({
where: { schluesselId: altId },
data: { zustand: "ENTFERNT" },
});
expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("ROTIERT");
expect((await rotationsStand(altId)).schritt).toBe(5);
expect((await rotationsKette(neuId)).map((g) => [g.id, g.status])).toEqual([
[altId, "ROTIERT"],
[neuId, "AKTIV"],
]);
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.uebernommen" } })).toBe(1);
expect(await datenbankAlsText()).not.toContain("PRIVATE KEY");
});
it("verweigert Personen-Keys und den Deployment-Key", async () => {
const person = await prisma.schluessel.create({
data: {
name: "Laptop",
slug: "laptop",
typ: "ED25519",
kategorie: "PERSON",
publicKey: zeile("ssh-ed25519", ed25519Blob(), "p"),
fingerprint: "SHA256:p",
kommentar: "p",
},
select: { id: true },
});
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { generateKeyPairSync } from "node:crypto";
import { describe, expect, it } from "vitest";
import { authorizedKeysAbgleichen } from "@/lib/ssh/abgleich";
import { blockSetzen, type SollEintrag } from "@/lib/ssh/authorized-keys";
import { fingerprintSha256 } from "@/lib/ssh/openssh";
/** Abgleich einer authorized_keys mit dem Soll (#021). */
function key(kommentar: string) {
const { publicKey } = generateKeyPairSync("ed25519");
const roh = publicKey.export({ format: "der", type: "spki" }).subarray(-32);
const typ = Buffer.from("ssh-ed25519");
const blob = Buffer.concat([Buffer.from([0, 0, 0, typ.length]), typ, Buffer.from([0, 0, 0, 32]), roh]);
return {
publicKey: `ssh-ed25519 ${blob.toString("base64")} ${kommentar}`,
fingerprint: fingerprintSha256(blob),
};
}
const DEPLOY = key("deploy");
const BACKUP = key("backup");
const FREMD = key("hacker@irgendwo");
const BEKANNT = key("bekannt");
const soll: SollEintrag[] = [DEPLOY, { ...BACKUP, optionen: 'from="10.0.0.1"' }];
const bekannt = new Set([DEPLOY.fingerprint, BACKUP.fingerprint, BEKANNT.fingerprint]);
describe("authorized_keys abgleichen", () => {
it("ist ohne Befund, wenn der Block dem Soll entspricht", () => {
const inhalt = blockSetzen(`${BEKANNT.publicKey}\n`, soll).inhalt;
const ergebnis = authorizedKeysAbgleichen(inhalt, soll, bekannt, {
sshVerzeichnis: "700",
datei: "600",
eigentuemerPasst: true,
});
expect(ergebnis.befunde).toEqual([]);
expect(ergebnis.vorhanden).toEqual([DEPLOY.fingerprint, BACKUP.fingerprint]);
expect(ergebnis.eintraege.map((e) => e.herkunft)).toEqual(["bekannt", "verwaltet", "verwaltet"]);
});
it("findet fehlende, geaenderte und zusaetzliche Keys im Block", () => {
const inhalt = blockSetzen("", [DEPLOY, BACKUP, FREMD]).inhalt; // BACKUP ohne Optionen, FREMD zu viel
const arten = authorizedKeysAbgleichen(inhalt, soll, bekannt).befunde.map((b) => [b.art, b.fingerprint]);
expect(arten).toEqual([
["ZUSAETZLICH_IM_BLOCK", BACKUP.fingerprint],
["ZUSAETZLICH_IM_BLOCK", FREMD.fingerprint],
]);
const leer = authorizedKeysAbgleichen(null, soll, bekannt).befunde.map((b) => b.art);
expect(leer).toEqual(["FEHLENDER_SCHLUESSEL", "FEHLENDER_SCHLUESSEL"]);
});
it("meldet nur unbekannte Keys ausserhalb des Blocks", () => {
const inhalt = blockSetzen(`${FREMD.publicKey}\n${FREMD.publicKey}\n${BEKANNT.publicKey}\n`, soll).inhalt;
const befunde = authorizedKeysAbgleichen(inhalt, soll, bekannt).befunde;
expect(befunde).toHaveLength(1);
expect(befunde[0]).toMatchObject({ art: "UNBEKANNTER_SCHLUESSEL", fingerprint: FREMD.fingerprint });
expect(befunde[0]?.text).toContain("hacker@irgendwo");
});
it("prueft Rechte und Eigentuemer", () => {
const befunde = authorizedKeysAbgleichen("", [], bekannt, {
sshVerzeichnis: "777",
datei: "664",
eigentuemerPasst: false,
}).befunde;
expect(befunde.map((b) => b.art)).toEqual(["RECHTE", "RECHTE", "RECHTE"]);
expect(
authorizedKeysAbgleichen("", [], bekannt, {
sshVerzeichnis: "755",
datei: "644",
eigentuemerPasst: true,
}).befunde,
).toEqual([]);
});
it("meldet einen beschaedigten Block", () => {
expect(authorizedKeysAbgleichen("# BEGIN SSHKV MANAGED\n", [], bekannt).befunde[0]?.art).toBe(
"DATEI_GEAENDERT",
);
});
});
+11
View File
@@ -209,3 +209,14 @@ describe("Diff", () => {
expect(kurz[0]?.text).toMatch(/8 unveränderte Zeile/); expect(kurz[0]?.text).toMatch(/8 unveränderte Zeile/);
}); });
}); });
describe("uebernommene Keys (#022)", () => {
it("entfernt nur ausdruecklich genannte Zeilen ausserhalb des Blocks", () => {
const vorher = `# Kommentar\n${FREMD.zeile}\n${BACKUP.zeile}\n`;
const ergebnis = blockSetzen(vorher, soll(FREMD), [], [FREMD.fingerprint]);
expect(ergebnis.inhalt).toBe(
`# Kommentar\n${BACKUP.zeile}\n# BEGIN SSHKV MANAGED – nicht manuell bearbeiten\n${FREMD.zeile}\n# END SSHKV MANAGED\n`,
);
expect(ergebnis.hinweise).toEqual([]);
});
});
+346
View File
@@ -0,0 +1,346 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { BESTAETIGUNG_NOETIG, RECHTE } from "@/server/auth/rechte";
import { schluesselListe } from "@/server/queries/schluessel";
import { widerrufUebersicht } from "@/server/queries/widerruf";
import {
VerwendungsFehler,
publicVerwendungAnlegen,
trockenlaufStarten,
} from "@/server/services/verwendungen";
import {
WiderrufFehler,
widerrufAbschliessen,
widerrufEinleiten,
widerrufZielkontenEntfernen,
zielkontoKandidaten,
} from "@/server/services/widerruf";
import { datenbankLeeren } from "./hilfen/db";
import { datenbankAlsText, warteschlangeStoppen } from "./hilfen/vorgang";
import { ed25519Blob, zeile } from "./ssh/hilfen";
/** Widerruf und Massenwiderruf (#024): Zustaende, Rechte, Protokoll. */
let admin: { id: string; name: string };
let a: { id: string; name: string };
let b: { id: string; name: string };
let zaehler = 0;
afterAll(async () => {
await warteschlangeStoppen();
});
beforeEach(async () => {
await datenbankLeeren();
admin = await prisma.benutzer.create({
data: { name: "Ada Admin", rolle: "ADMIN" },
select: { id: true, name: true },
});
a = await prisma.host.create({
data: { name: "srv-a", adresse: "10.0.0.1" },
select: { id: true, name: true },
});
b = await prisma.host.create({
data: { name: "srv-b", adresse: "10.0.0.2" },
select: { id: true, name: true },
});
});
async function schluessel(name: string, daten: { kategorie?: "PERSON" | "DIENST" | "DEPLOYMENT" } = {}) {
zaehler += 1;
return prisma.schluessel.create({
data: {
name,
slug: `k-${zaehler}`,
typ: "ED25519",
kategorie: daten.kategorie ?? "DIENST",
publicKey: zeile("ssh-ed25519", ed25519Blob(), `k-${zaehler}`),
fingerprint: `SHA256:test${zaehler}`,
kommentar: `k-${zaehler}`,
},
select: { id: true, name: true, fingerprint: true },
});
}
/** Public auf B (ausgerollt) und Private auf A (ausgerollt) – wie nach dem Assistenten. */
async function ausgerollt(schluesselId: string, benutzerB = "deploy") {
const jetzt = new Date();
const oeffentlich = await prisma.publicVerwendung.create({
data: {
schluesselId,
hostId: b.id,
zielbenutzer: benutzerB,
zustand: "AUSGEROLLT",
zuletztAngewendetAm: jetzt,
},
});
const privat = await prisma.privateVerwendung.create({
data: {
schluesselId,
hostId: a.id,
zielbenutzer: "root",
pfad: `~/.ssh/id_${schluesselId}`,
sshConfig: { alias: "zu-b", hostName: "srv-b", benutzer: benutzerB, port: 22 },
zustand: "AUSGEROLLT",
zuletztAngewendetAm: jetzt,
},
});
return { oeffentlich, privat };
}
async function fehlerVon(versuch: Promise<unknown>): Promise<Error> {
const fehler = await versuch.then(
() => undefined,
(f: unknown) => f,
);
expect(fehler).toBeInstanceOf(Error);
return fehler as Error;
}
describe("Widerruf einleiten", () => {
it("merkt alle Verwendungen zum Entfernen vor und laesst den Status noch AKTIV", async () => {
const s = await schluessel("Backup");
const { oeffentlich, privat } = await ausgerollt(s.id);
// Nie ausgerollt: auf dem Host steht nichts, die Verwendung ist sofort erledigt.
const geplant = await prisma.publicVerwendung.create({
data: { schluesselId: s.id, hostId: a.id, zielbenutzer: "root" },
});
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
const nachher = await prisma.schluessel.findUniqueOrThrow({
where: { id: s.id },
select: { status: true, widerrufEingeleitetAm: true },
});
expect(nachher.status).toBe("AKTIV");
expect(nachher.widerrufEingeleitetAm).not.toBeNull();
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: oeffentlich.id } })).zustand).toBe(
"ENTFERNEN",
);
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: geplant.id } })).zustand).toBe(
"ENTFERNT",
);
expect((await prisma.privateVerwendung.findUniqueOrThrow({ where: { id: privat.id } })).zustand).toBe(
"ENTFERNEN",
);
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.widerruf.eingeleitet" },
});
expect(eintrag.fingerprint).toBe(s.fingerprint);
expect(eintrag.akteurName).toBe("Ada Admin");
expect(eintrag.details).toMatchObject({ public: 2, privat: 1, ohneHostZugriff: 1 });
expect(await datenbankAlsText()).not.toContain("PRIVATE KEY");
});
it("ist idempotent und sperrt neue Zuweisungen", async () => {
const s = await schluessel("Backup");
await ausgerollt(s.id);
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
const erstes = (await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).widerrufEingeleitetAm;
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
expect(
(await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).widerrufEingeleitetAm,
).toEqual(erstes);
const fehler = await fehlerVon(
publicVerwendungAnlegen({ schluesselId: s.id, hostId: a.id, zielbenutzer: "backup" }, admin),
);
expect(fehler).toBeInstanceOf(VerwendungsFehler);
expect(fehler.message).toMatch(/widerrufen/);
});
it("verlangt fuer Private Keys das Admin-Recht", async () => {
const s = await schluessel("Backup");
await ausgerollt(s.id);
const fehler = await fehlerVon(widerrufEinleiten([s.id], admin, { privatErlaubt: false }));
expect(fehler).toBeInstanceOf(WiderrufFehler);
expect(fehler.message).toMatch(/nur ein Admin/);
// Nichts vorgemerkt.
expect(await prisma.publicVerwendung.count({ where: { zustand: "ENTFERNEN" } })).toBe(0);
// Ein reiner Public-Key-Widerruf geht auch ohne.
const person = await schluessel("Laptop Alice", { kategorie: "PERSON" });
await prisma.publicVerwendung.create({
data: { schluesselId: person.id, hostId: b.id, zielbenutzer: "alice", zustand: "AUSGEROLLT" },
});
await widerrufEinleiten([person.id], admin, { privatErlaubt: false });
});
it("weist den Deployment-Key und bereits widerrufene Schluessel ab", async () => {
const deployment = await schluessel("Deployment", { kategorie: "DEPLOYMENT" });
expect(
(await fehlerVon(widerrufEinleiten([deployment.id], admin, { privatErlaubt: true }))).message,
).toMatch(/Deployment-Key/);
const alt = await schluessel("Alt");
await prisma.schluessel.update({ where: { id: alt.id }, data: { status: "WIDERRUFEN" } });
expect((await fehlerVon(widerrufEinleiten([alt.id], admin, { privatErlaubt: true }))).message).toMatch(
/bereits widerrufen/,
);
});
it("erledigt Verwendungen auf archivierten Hosts sofort", async () => {
const s = await schluessel("Backup");
const v = await prisma.publicVerwendung.create({
data: { schluesselId: s.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
await prisma.host.update({ where: { id: b.id }, data: { archiviertAm: new Date() } });
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: v.id } })).zustand).toBe(
"ENTFERNT",
);
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.widerruf.eingeleitet" },
});
expect(eintrag.details).toMatchObject({ archivierteHosts: ["srv-b"] });
});
});
describe("Dry-Run fuer den Widerruf", () => {
it("nimmt die zum Entfernen vorgemerkten Ziele mehrerer Schluessel in einen Trockenlauf", async () => {
const eins = await schluessel("Eins");
const zwei = await schluessel("Zwei");
await ausgerollt(eins.id);
await prisma.publicVerwendung.create({
data: { schluesselId: zwei.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
await widerrufEinleiten([eins.id, zwei.id], admin, { privatErlaubt: true });
const id = await trockenlaufStarten({ schluesselIds: [eins.id, zwei.id] }, admin, true);
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id } });
expect(vorgang.art).toBe("TROCKENLAUF");
expect(vorgang.schluesselId).toBeNull();
const ziele = (vorgang.eingabe as { ziele: { hostId: string; zielbenutzer: string; art?: string }[] })
.ziele;
// Beide Schluessel auf B/deploy ergeben ein Ziel, dazu der Private Key auf A.
expect(ziele).toHaveLength(2);
expect(ziele).toContainEqual({ hostId: b.id, zielbenutzer: "deploy" });
expect(ziele).toContainEqual({ hostId: a.id, zielbenutzer: "root", art: "privat" });
});
});
describe("Widerruf abschliessen", () => {
it("bleibt im Zwischenzustand, solange eine Entfernung offen ist", async () => {
const s = await schluessel("Backup");
const { oeffentlich, privat } = await ausgerollt(s.id);
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
// B war nicht erreichbar: der Private Key auf A ist weg, der Public Key auf B noch nicht.
await prisma.privateVerwendung.update({ where: { id: privat.id }, data: { zustand: "ENTFERNT" } });
const [ergebnis] = await widerrufAbschliessen([s.id], admin);
expect(ergebnis?.status).toBe("OFFEN");
expect(ergebnis?.offen.map((o) => o.ziel)).toEqual(["deploy@srv-b:~/.ssh/authorized_keys"]);
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("AKTIV");
expect((await widerrufUebersicht([s.id])).offen).toHaveLength(1);
await prisma.publicVerwendung.update({ where: { id: oeffentlich.id }, data: { zustand: "ENTFERNT" } });
const [fertig] = await widerrufAbschliessen([s.id], admin);
expect(fertig?.status).toBe("WIDERRUFEN");
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("WIDERRUFEN");
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.widerrufen" } });
expect(eintrag.fingerprint).toBe(s.fingerprint);
// Ein zweiter Abschluss aendert nichts und protokolliert nicht doppelt.
await widerrufAbschliessen([s.id], admin);
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.widerrufen" } })).toBe(1);
});
it("schliesst ohne eingeleiteten Widerruf nicht ab", async () => {
const s = await schluessel("Backup");
expect((await fehlerVon(widerrufAbschliessen([s.id], admin))).message).toMatch(
/kein Widerruf eingeleitet/,
);
});
it("setzt ROTIERT, wenn ein aktiver Nachfolger existiert (#023)", async () => {
const alt = await schluessel("Alt");
const neu = await schluessel("Neu");
await prisma.schluessel.update({ where: { id: neu.id }, data: { vorgaengerId: alt.id } });
await widerrufEinleiten([alt.id], admin, { privatErlaubt: true });
const [ergebnis] = await widerrufAbschliessen([alt.id], admin);
expect(ergebnis?.status).toBe("ROTIERT");
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotiert" } })).toBe(1);
});
});
describe("Zielkonten beim Widerruf", () => {
it("bietet nur von sshkv angelegte Konten an und nennt weitere Nutzer", async () => {
const s = await schluessel("Deploy A");
const andere = await schluessel("Deploy C");
await ausgerollt(s.id);
const konto = await prisma.zielkonto.create({ data: { hostId: b.id, benutzername: "deploy" } });
// Ein vorhandenes, nicht von sshkv angelegtes Konto taucht nicht auf.
await prisma.zielkonto.create({ data: { hostId: a.id, benutzername: "root2", vonSshkvAngelegt: false } });
expect(await zielkontoKandidaten([s.id])).toEqual([
expect.objectContaining({
id: konto.id,
benutzername: "deploy",
hostName: "srv-b",
nochGenutztVon: [],
}),
]);
await prisma.publicVerwendung.create({
data: { schluesselId: andere.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
expect((await zielkontoKandidaten([s.id]))[0]?.nochGenutztVon).toEqual(["Deploy C"]);
// Beide gemeinsam widerrufen: niemand nutzt das Konto weiter.
expect((await zielkontoKandidaten([s.id, andere.id]))[0]?.nochGenutztVon).toEqual([]);
});
it("entfernt Zielkonten erst nach den Schluesseln und nie ein noch genutztes", async () => {
const s = await schluessel("Deploy A");
const { oeffentlich, privat } = await ausgerollt(s.id);
const konto = await prisma.zielkonto.create({ data: { hostId: b.id, benutzername: "deploy" } });
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
expect((await fehlerVon(widerrufZielkontenEntfernen([s.id], [konto.id], admin))).message).toMatch(
/erst entfernt, wenn die Schlüssel/,
);
await prisma.publicVerwendung.update({ where: { id: oeffentlich.id }, data: { zustand: "ENTFERNT" } });
await prisma.privateVerwendung.update({ where: { id: privat.id }, data: { zustand: "ENTFERNT" } });
const andere = await schluessel("Deploy C");
const fremd = await prisma.publicVerwendung.create({
data: { schluesselId: andere.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
expect((await fehlerVon(widerrufZielkontenEntfernen([s.id], [konto.id], admin))).message).toMatch(
/noch von „Deploy C“ genutzt/,
);
await prisma.publicVerwendung.delete({ where: { id: fremd.id } });
const vorgaenge = await widerrufZielkontenEntfernen([s.id], [konto.id], admin);
expect(vorgaenge).toEqual([{ id: expect.any(String), beschriftung: "deploy@srv-b" }]);
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgaenge[0]!.id } });
expect(vorgang).toMatchObject({
art: "ZIELKONTO_ENTFERNEN",
hostId: b.id,
eingabe: { benutzer: "deploy" },
});
});
});
describe("Rechte und Ablauf", () => {
it("Massenwiderruf und Zielkonten nur fuer Admins mit frischer Bestaetigung", () => {
expect(RECHTE.massenwiderruf).toEqual(["ADMIN"]);
expect(BESTAETIGUNG_NOETIG.has("massenwiderruf")).toBe(true);
expect(BESTAETIGUNG_NOETIG.has("zielkontenVerwalten")).toBe(true);
});
it("zeigt aktive Schluessel mit vergangenem Ablaufdatum unter „abgelaufen“", async () => {
const s = await schluessel("Projekt X");
const frisch = await schluessel("Projekt Y");
await prisma.schluessel.update({
where: { id: s.id },
data: { ablaufAm: new Date("2020-01-01T00:00:00Z") },
});
await prisma.schluessel.update({
where: { id: frisch.id },
data: { ablaufAm: new Date("2999-01-01T00:00:00Z") },
});
expect((await schluesselListe({ status: "ABGELAUFEN" })).map((k) => k.name)).toEqual(["Projekt X"]);
expect((await schluesselListe({ status: "AKTIV" })).map((k) => k.name)).toEqual(["Projekt Y"]);
// Automatisch entfernt wird nichts: der Status in der Datenbank bleibt AKTIV.
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("AKTIV");
});
});
+150
View File
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@/lib/db";
import { zellenSchluessel } from "@/lib/zuweisungsmatrix";
import { zuweisungsDaten, zuweisungsMatrix } from "@/server/queries/zuweisungen";
import { datenbankLeeren } from "./hilfen/db";
/**
* Zuweisungsmatrix (#025) gegen die Testdatenbank: Query und CSV-Route. Die Sitzung wird fuer
* die Route nachgebildet – geprueft wird, dass der Handler das Recht selbst verlangt.
*/
const recht = vi.hoisted(() => ({ erlaubt: true }));
vi.mock("@/server/auth/sitzungspflicht", async (original) => {
const echt = await original<typeof import("@/server/auth/sitzungspflicht")>();
return {
...echt,
rechtErforderlich: vi.fn(async () => {
if (!recht.erlaubt) throw new echt.RechteFehler("Nicht angemeldet.");
return { benutzer: { id: "b", rolle: "LESER" } };
}),
};
});
let zaehler = 0;
async function schluesselAnlegen(daten: {
name: string;
status?: "AKTIV" | "WIDERRUFEN";
mitPrivat?: boolean;
}) {
zaehler += 1;
return prisma.schluessel.create({
data: {
name: daten.name,
slug: `test-${zaehler}`,
typ: "ED25519",
kategorie: "DIENST",
status: daten.status ?? "AKTIV",
publicKey: "ssh-ed25519 AAAA test",
fingerprint: `SHA256:test${zaehler}`,
kommentar: "test",
...(daten.mitPrivat
? { privatVerschluesselt: Buffer.from("x"), dekVerschluesselt: Buffer.from("y"), masterKeyVersion: 1 }
: {}),
},
});
}
let ids: Record<string, string>;
beforeEach(async () => {
await datenbankLeeren();
recht.erlaubt = true;
zaehler = 0;
const gruppe = await prisma.hostgruppe.create({ data: { name: "pve" } });
const [pve1, pve2, alt] = await Promise.all([
prisma.host.create({ data: { name: "srv-pve01", adresse: "10.0.0.1" } }),
prisma.host.create({ data: { name: "srv-pve02", adresse: "10.0.0.2" } }),
prisma.host.create({ data: { name: "srv-alt", adresse: "10.0.0.3", archiviertAm: new Date() } }),
]);
await prisma.hostgruppeMitglied.createMany({
data: [pve1, pve2, alt].map((h) => ({ hostId: h.id, hostgruppeId: gruppe.id })),
});
const backup = await schluesselAnlegen({ name: "Backup", mitPrivat: true });
const formel = await schluesselAnlegen({ name: "=1+1" });
const widerrufen = await schluesselAnlegen({ name: "Alt", status: "WIDERRUFEN" });
await prisma.publicVerwendung.createMany({
data: [
{ schluesselId: backup.id, hostgruppeId: gruppe.id, zielbenutzer: "root", optionen: "restrict" },
{ schluesselId: formel.id, hostId: pve1.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
{ schluesselId: formel.id, hostId: pve2.id, zielbenutzer: "root", zustand: "ENTFERNT" },
{ schluesselId: widerrufen.id, hostId: pve1.id, zielbenutzer: "root" },
],
});
await prisma.privateVerwendung.create({
data: { schluesselId: backup.id, hostId: pve2.id, zielbenutzer: "backup", pfad: "~/.ssh/id_backup" },
});
ids = {
gruppe: gruppe.id,
pve1: pve1.id,
pve2: pve2.id,
alt: alt.id,
backup: backup.id,
formel: formel.id,
};
});
describe("zuweisungsDaten", () => {
it("liest nur aktive Schluessel, nicht archivierte Hosts und keine entfernten Verwendungen", async () => {
const daten = await zuweisungsDaten();
expect(daten.schluessel.map((s) => s.name)).toEqual(["=1+1", "Backup"]);
expect(daten.hosts.map((h) => h.name)).toEqual(["srv-pve01", "srv-pve02"]);
expect(daten.hosts[0]!.gruppenIds).toEqual([ids["gruppe"]]);
expect(daten.oeffentlich).toHaveLength(2);
expect(daten.privat).toHaveLength(1);
});
it("liefert nie Chiffrat-Felder", async () => {
const text = JSON.stringify(await zuweisungsDaten());
for (const feld of ["privatVerschluesselt", "dekVerschluesselt", "masterKeyVersion", "publicKey"]) {
expect(text).not.toContain(feld);
}
});
});
describe("zuweisungsMatrix", () => {
it("loest die Gruppe auf die nicht archivierten Mitglieder auf", async () => {
const { matrix, gruppen } = await zuweisungsMatrix();
expect(gruppen.map((g) => g.name)).toEqual(["pve"]);
expect(matrix.zellen.get(zellenSchluessel(ids["backup"]!, ids["pve1"]!))?.kennungen).toEqual(["P·G"]);
expect(matrix.zellen.get(zellenSchluessel(ids["backup"]!, ids["pve2"]!))?.kennungen).toEqual([
"P·G",
"K",
]);
expect(matrix.zellen.has(zellenSchluessel(ids["backup"]!, ids["alt"]!))).toBe(false);
expect(matrix.zaehler).toEqual({ schluessel: 2, hosts: 2, zuweisungen: 4, nichtSynchron: 3 });
});
});
describe("GET /api/zuweisungen.csv", () => {
async function abrufen(query = "") {
const { GET } = await import("@/app/api/zuweisungen.csv/route");
return GET(new Request(`http://localhost/api/zuweisungen.csv${query}`));
}
it("verweigert ohne Recht", async () => {
recht.erlaubt = false;
const antwort = await abrufen();
expect(antwort.status).toBe(403);
});
it("liefert die Matrix als Excel-sichere CSV", async () => {
const antwort = await abrufen();
expect(antwort.status).toBe(200);
expect(antwort.headers.get("content-type")).toContain("text/csv");
expect(antwort.headers.get("content-disposition")).toContain("sshkv-zuweisungen.csv");
const text = await antwort.text();
// Response.text() entfernt die BOM nicht immer – beides zulassen.
const zeilen = text.replace(/^/, "").trimEnd().split("\r\n");
expect(zeilen).toHaveLength(1 + 4);
expect(zeilen).toContain(
'"Backup";"SHA256:test1";"srv-pve01";"root";"Public Key";"pve";"restrict";"noch nicht ausgerollt"',
);
expect(zeilen).toContain(`"'=1+1";"SHA256:test2";"srv-pve01";"deploy";"Public Key";"";"";"synchron"`);
});
it("wendet die Filter der Seite an", async () => {
const text = await (await abrufen("?q=srv-pve02")).text();
expect(text).toContain("srv-pve02");
expect(text).not.toContain("srv-pve01");
});
});
+210
View File
@@ -0,0 +1,210 @@
import { describe, expect, it } from "vitest";
import { csvEntschaerfen, csvZeile } from "@/lib/csv";
import {
matrixAufbauen,
matrixCsv,
matrixFilterAus,
matrixFilterQuery,
zellenSchluessel,
type MatrixDaten,
type PublicZeile,
} from "@/lib/zuweisungsmatrix";
/** Zuweisungsmatrix (#025): reine Funktion, Gruppenaufloesung, Filter, CSV. */
const backup = {
id: "s-backup",
name: "Backup pve",
fingerprint: "SHA256:backup",
kategorie: "DIENST" as const,
person: null,
};
const anna = {
id: "s-anna",
name: "Anna Laptop",
fingerprint: "SHA256:anna",
kategorie: "PERSON" as const,
person: { id: "p-anna", name: "Anna" },
};
const leer = {
id: "s-leer",
name: "Unbenutzt",
fingerprint: "SHA256:leer",
kategorie: "DIENST" as const,
person: null,
};
const public_ = (
abweichung: Partial<PublicZeile> & Pick<PublicZeile, "id" | "schluesselId">,
): PublicZeile => ({
hostId: null,
hostgruppeId: null,
zielbenutzer: "root",
optionen: null,
zustand: "AUSGEROLLT",
...abweichung,
});
const DATEN: MatrixDaten = {
schluessel: [anna, backup, leer],
hosts: [
{ id: "h-pve1", name: "srv-pve01", gruppenIds: ["g-pve"] },
{ id: "h-pve2", name: "srv-pve02", gruppenIds: ["g-pve"] },
{ id: "h-web", name: "srv-web01", gruppenIds: [] },
],
gruppen: [{ id: "g-pve", name: "pve" }],
oeffentlich: [
public_({ id: "v-gruppe", schluesselId: "s-backup", hostgruppeId: "g-pve", optionen: "restrict" }),
public_({
id: "v-direkt",
schluesselId: "s-anna",
hostId: "h-pve1",
zielbenutzer: "anna",
zustand: "GEPLANT",
}),
public_({
id: "v-direkt2",
schluesselId: "s-anna",
hostId: "h-pve1",
zielbenutzer: "root",
zustand: "ABWEICHUNG",
}),
public_({ id: "v-weg", schluesselId: "s-anna", hostId: "h-web", zustand: "ENTFERNT" }),
],
privat: [
{
id: "k-1",
schluesselId: "s-backup",
hostId: "h-web",
zielbenutzer: "backup",
pfad: "~/.ssh/id_backup",
zustand: "FEHLGESCHLAGEN",
},
],
};
describe("matrixAufbauen", () => {
it("loest Gruppen-Zuweisungen je Mitglied auf und kennzeichnet sie", () => {
const m = matrixAufbauen(DATEN);
for (const host of ["h-pve1", "h-pve2"]) {
const zelle = m.zellen.get(zellenSchluessel("s-backup", host))!;
expect(zelle.kennungen).toEqual(["P·G"]);
expect(zelle.eintraege[0]).toMatchObject({
verwendungId: "v-gruppe",
gruppe: { name: "pve" },
optionen: "restrict",
});
}
});
it("unterscheidet direkt, Gruppe und Private Key", () => {
const m = matrixAufbauen(DATEN);
expect(m.zellen.get(zellenSchluessel("s-anna", "h-pve1"))!.kennungen).toEqual(["P"]);
const privat = m.zellen.get(zellenSchluessel("s-backup", "h-web"))!;
expect(privat.kennungen).toEqual(["K"]);
expect(privat.eintraege[0]).toMatchObject({ art: "privat", pfad: "~/.ssh/id_backup" });
});
it("zeigt den dringendsten Zustand und alle Zielbenutzer", () => {
const zelle = matrixAufbauen(DATEN).zellen.get(zellenSchluessel("s-anna", "h-pve1"))!;
expect(zelle.zustand).toBe("ABWEICHUNG");
expect(zelle.zielbenutzer).toEqual(["anna", "root"]);
});
it("laesst entfernte Verwendungen weg", () => {
expect(matrixAufbauen(DATEN).zellen.has(zellenSchluessel("s-anna", "h-web"))).toBe(false);
});
it("zaehlt Zuweisungen je Host und die nicht synchronen", () => {
expect(matrixAufbauen(DATEN).zaehler).toEqual({
schluessel: 3,
hosts: 3,
zuweisungen: 5,
nichtSynchron: 3,
});
});
it("filtert Spalten nach Hostgruppe und Zeilen nach Kategorie und Person", () => {
expect(matrixAufbauen(DATEN, { gruppeId: "g-pve" }).spalten.map((h) => h.id)).toEqual([
"h-pve1",
"h-pve2",
]);
expect(matrixAufbauen(DATEN, { kategorie: "PERSON" }).zeilen.map((s) => s.id)).toEqual(["s-anna"]);
expect(matrixAufbauen(DATEN, { personId: "p-anna" }).zeilen.map((s) => s.id)).toEqual(["s-anna"]);
});
it("sucht in Schluessel- und Hostnamen", () => {
const nachSchluessel = matrixAufbauen(DATEN, { q: "backup" });
expect(nachSchluessel.zeilen.map((s) => s.id)).toEqual(["s-backup"]);
expect(nachSchluessel.spalten).toHaveLength(3);
const nachHost = matrixAufbauen(DATEN, { q: "WEB01" });
expect(nachHost.zeilen).toHaveLength(3);
expect(nachHost.spalten.map((h) => h.id)).toEqual(["h-web"]);
const nichts = matrixAufbauen(DATEN, { q: "gibtsnicht" });
expect(nichts.zeilen).toHaveLength(0);
expect(nichts.spalten).toHaveLength(0);
});
it("blendet mit „nur belegt“ leere Zeilen und Spalten aus", () => {
const m = matrixAufbauen(DATEN, { nurBelegt: true, gruppeId: "g-pve" });
expect(m.zeilen.map((s) => s.id)).toEqual(["s-anna", "s-backup"]);
expect(m.spalten.map((h) => h.id)).toEqual(["h-pve1", "h-pve2"]);
expect(matrixAufbauen(DATEN, { nurBelegt: true, q: "unbenutzt" }).zeilen).toHaveLength(0);
});
it("zaehlt nur, was in der gefilterten Ansicht steht", () => {
expect(matrixAufbauen(DATEN, { gruppeId: "g-pve" }).zaehler.zuweisungen).toBe(4);
});
});
describe("Filter aus GET-Parametern", () => {
it("liest bekannte Werte und verwirft unbekannte Kategorien", () => {
const filter = matrixFilterAus({
gruppe: "g1",
kategorie: "QUATSCH",
person: ["p1", "p2"],
q: " pve ",
belegt: "1",
});
expect(filter).toEqual({ gruppeId: "g1", kategorie: "", personId: "p1", q: "pve", nurBelegt: true });
expect(matrixFilterQuery(filter)).toBe("?gruppe=g1&person=p1&q=pve&belegt=1");
expect(matrixFilterQuery(matrixFilterAus({}))).toBe("");
});
});
describe("CSV", () => {
it("entschaerft Formelbeginne fuer Excel", () => {
for (const wert of ["=1+1", "+49", "-x", "@SUMME(A1)", "\tx"])
expect(csvEntschaerfen(wert)).toBe(`'${wert}`);
expect(csvEntschaerfen("root")).toBe("root");
expect(csvZeile(['restrict,from="10.0.0.1"', null])).toBe('"restrict,from=""10.0.0.1""";""');
});
it("schreibt eine Zeile je Zelleneintrag mit Kopf und BOM", () => {
const csv = matrixCsv(matrixAufbauen(DATEN));
expect(csv.startsWith("")).toBe(true);
const zeilen = csv.slice(1).trimEnd().split("\r\n");
expect(zeilen[0]).toBe(
'"Schlüssel";"Fingerprint";"Host";"Zielbenutzer";"Art";"Über Gruppe";"Optionen";"Zustand"',
);
expect(zeilen).toHaveLength(1 + 5);
expect(zeilen).toContain(
'"Backup pve";"SHA256:backup";"srv-pve02";"root";"Public Key";"pve";"restrict";"synchron"',
);
expect(zeilen).toContain(
'"Backup pve";"SHA256:backup";"srv-web01";"backup";"Private Key";"";"Pfad: ~/.ssh/id_backup";"fehlgeschlagen"',
);
});
it("entschaerft boesartige Schluesselnamen im Export", () => {
const daten: MatrixDaten = {
...DATEN,
schluessel: [{ ...backup, name: '=HYPERLINK("http://x","klick")' }],
};
const csv = matrixCsv(matrixAufbauen(daten));
expect(csv).toContain(`"'=HYPERLINK(""http://x"",""klick"")"`);
expect(csv).not.toMatch(/(^|;|\n)"=/);
});
});