20 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
florian.wilde 2dad8e5631 Merge branch 'phase/release-0.3.0' 2026-10-12 11:25:17 +02:00
florian.wildeandClaude Opus 5.5 2c658b1223 chore(release): 0.3.0 - Phase 3 abgeschlossen
Host-zu-Host-Assistent, Zielkonten mit sudoers, Vorlagen. CHANGELOG
und DEVLOG nachgetragen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:25:16 +02:00
florian.wilde 00cff56500 Merge branch 'phase/018-020-assistent' 2026-10-12 11:19:33 +02:00
florian.wilde 685b76657d Merge branch 'phase/019-vorlagen-oberflaeche' 2026-10-12 11:19:33 +02:00
florian.wildeandClaude Opus 5.5 b80f14c5d7 feat(assistent): Host-zu-Host-Assistent mit Vorlagen (#018, #020)
Ein Durchgang fuer den Kern-Anwendungsfall: Vorlage und Zweck, Host A,
Host B mit Zielbenutzer, Zusammenfassung, dann Schluessel erzeugen,
Benutzer anlegen, zuweisen, Dry-Run, ausrollen und Verbindung testen.
Die Vorlage Deployment-Zugang fuellt deploy, Ablaufdatum, from= und
Alias vor; bei root als Ziel warnt der Assistent ausdruecklich.

Die Ausfuehrung ist wiederaufnehmbar: wird mittendrin eine frische
Bestaetigung fuer das Zielkonto noetig, geschieht sie in einem neuen
Tab, und nichts wird doppelt angelegt. Zielkonten laufen als eigene
Vorgaenge (neue Migration) und merken sich, ob sshkv den Benutzer
angelegt hat - nur dann loescht ein Widerruf ihn spaeter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:19:28 +02:00
florian.wildeandClaude Opus 5.5 458f62289a test(vorlagen): Service-, E2E-Tests und Screenshots (#019)
Der Service war nur ueber die reinen Pruefregeln abgedeckt. Die
DB-Tests halten fest, was die Oberflaeche voraussetzt: doppelte Namen
melden sich am Feld name, Aenderungen protokollieren genau die
geaenderten Felder, eingebaute Vorlagen lassen sich nicht archivieren.
Der E2E-Ablauf prueft Feldfehler, Vorschau und Rechte im gebauten
Stand; die Screenshots dienen der Layout-Abnahme bei 390 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:16:42 +02:00
florian.wildeandClaude Opus 5.5 5c42f8dd8d feat(vorlagen): Liste, Formular und Live-Vorschau (#019)
Die Vorlagen-Logik aus #019 war bisher nur per Seed und Service
erreichbar. Admins koennen eigene Vorlagen jetzt anlegen, eingebaute
anpassen und eigene archivieren; jede Rolle sieht die Liste, weil
Operatoren die Vorlagen im Assistenten wiedererkennen sollen.

Die Vorschau nutzt im Browser dieselben reinen Funktionen wie der
Server (platzhalterErsetzen, optionenPruefen, sudoersInhalt). So sieht
der Admin vor dem Speichern, was in authorized_keys, ~/.ssh/config und
sudoers landen wuerde; verbindlich prueft weiterhin der Server. Voller
sudo-Zugang bekommt einen deutlichen Hinweis, weil er root gleichkommt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:16:31 +02:00
114 changed files with 9109 additions and 133 deletions
+33
View File
@@ -15,6 +15,39 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
## [0.4.0] – 2026-10-12
Phase 4: Abgleich, Import, Rotation, Widerruf und Zuweisungsmatrix.
### Hinzugefügt
- Abgleich je Host und nächtlich per Zeitplan (`SSHKV_SYNC_CRON`): erkennt fehlende Schlüssel, von Hand geänderte Blöcke, unbekannte Schlüssel, falsche Rechte, beschädigte Blöcke und veränderte Private Keys; Befunde öffnen und schließen sich selbst. Hostseite mit Abweichungen und Ist-Zustand je Benutzer, „Jetzt abgleichen“ auch auf der Übersicht (#021)
- Unbekannte Schlüssel übernehmen: als bekannt aufnehmen oder per Dry-Run in den verwalteten Block holen (#022)
- Geführte Rotation: neuer Schlüssel an allen Stellen, Verbindungstests, erst dann wird der alte entfernt; jederzeit fortsetzbar, Rotationskette auf der Schlüsselseite (#023)
- Widerruf mit einem Klick und Massenwiderruf: Public Key, Private Key, config- und known_hosts-Einträge entfernen, optional Zielkonten; Zwischenzustand, solange ein Host nicht erreicht wird; abgelaufene Schlüssel mit Hinweis (#024)
- Zuweisungsmatrix Schlüssel × Host mit Filtern, Zell-Dialog zum Zuweisen und CSV-Export (#025)
- Skript zum Abgleich der Issues nach Gitea (`pnpm issues:sync`, #030 – Zugang steht noch aus)
### Sicherheit
- Abgleich liest Dateien als der jeweilige Benutzer und ändert auf dem Host nichts
- Zeilen außerhalb des verwalteten Blocks entfernt sshkv nur bei ausdrücklich übernommenen Schlüsseln
- Ein Widerruf gilt erst als abgeschlossen, wenn der Schlüssel wirklich überall entfernt ist; eine Rotation entfernt den alten Schlüssel erst nach erfolgreichen Verbindungstests
- CSV-Export entschärft Werte, die eine Tabellenkalkulation als Formel lesen würde
## [0.3.0] – 2026-10-12
Phase 3: Host-zu-Host – Private Keys, Zielkonten, Vorlagen und Assistent.
### Hinzugefügt
- Host-zu-Host-Assistent: Vorlage wählen, Host A und B, Zielbenutzer, Einschränkungen und `~/.ssh/config`-Alias, Fingerprint von B bestätigen, Dry-Run, ausrollen, Verbindung testen – in einem Durchgang (#020)
- Zielbenutzer (z. B. `deploy`) auf dem Ziel anlegen, mit sudoers-Datei für bestimmte Befehle oder vollen Zugriff; Vorschau vor dem Anlegen, Prüfung mit `visudo -c` (#018)
- Vorlagen verwalten: eingebaute „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“, eigene anlegen und archivieren, Live-Vorschau der Platzhalter und des sudoers-Inhalts (#019)
- Übersicht verlinkt den Assistenten
### Sicherheit
- Neue Benutzer mit sudo nur durch Admins mit frischer Bestätigung
- sshkv überschreibt keine fremde sudoers-Datei und fasst Systemkonten nicht an; ein Widerruf löscht nur Benutzer, die sshkv selbst angelegt hat
- Platzhalter-Werte können keine zusätzlichen authorized_keys-Optionen einschleusen
## [0.2.0] – 2026-10-12
Phase 2: Schlüssel, Personen und Ausrollen von Public Keys – dazu vorgezogen aus Phase 3 das Ausrollen von Private Keys.
+47 -5
View File
@@ -2,8 +2,8 @@
## Zeitübersicht
**Gesamt (Wandzeit):** 2 h 25 min
**Summe der Issue-Zeiten:** 7 h 08 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011 und #012–#014.
**Gesamt (Wandzeit):** 3 h 40 min
**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 |
|---|---|
@@ -25,14 +25,22 @@
| #015 authorized_keys-Parser | 0 h 15 min |
| #016 Public Key ausrollen | 0 h 40 min |
| #017 Private Key ausrollen | 0 h 45 min |
| #018 Zielbenutzer und sudoers (Teil) | 0 h 10 min |
| #019 Vorlagen (Teil) | 0 h 10 min |
| #020 Verbindungstest (Teil) | 0 h 05 min |
| #018 Zielbenutzer und sudoers | 0 h 30 min |
| #019 Vorlagen | 0 h 20 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 |
|---|---|
| v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen**
| v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen**
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
---
@@ -84,3 +92,37 @@
- **Private-Key-Ziele im Dry-Run nur für Admins:** Sonst hätte ein Operator einen vorbereiteten Private Key ausrollen lassen können (Freigabe Frage 3).
- **Keine Sicherungskopie alter Private Keys** auf dem Host; config-Block am Dateianfang, weil ssh den ersten Treffer nimmt.
- **Ein Hook `useVorgang` für alle:** zwei parallel entstandene Fassungen zusammengeführt, beide Schnittstellen bleiben.
## 2026-10-12 · Sitzung 3 · 11:06–11:36
**Issue:** #018–#020
**Version:** 0.3.0
### Erledigt
- #019 Vorlagen-Oberfläche (Liste, Formular, Live-Vorschau) von einem Sub-Agenten, gemergt
- #018 Zielkonten als Vorgänge mit Migration, Herkunft gemerkt; Integrationstest anlegen → entfernen
- #020 Host-zu-Host-Assistent mit Vorlagen, wiederaufnehmbarer Ausführung und Verbindungstest; E2E mit „Deployment-Zugang“
- Stand: 423 Unit-/DB-Tests, 31 Integrationstests, 16 E2E-Abläufe grün
### Entscheidungen
- **Migration auf Ortszeit-Reihenfolge umbenannt** (`20261012110000`): Prisma vergibt UTC-Zeitstempel, die Migrationen des Agenten trugen Ortszeit – sonst stünde die neue vor älteren.
- **Bestätigung im neuen Tab:** Der Assistent hält seine Eingaben im Speicher; eine Umleitung nach `/bestaetigen` hätte sie gelöscht.
- **Ausführung des Assistenten als Kette im Klick** statt als Hook-Zustandsmaschine – einfacher, und erledigte Schritte lassen sich beim Fortsetzen überspringen.
- **Prozesse stoppen nur über ein Skript, das Programm und Arbeitsverzeichnis prüft:** Zweimal hatte ein zu breites Suchmuster die eigene Shell getroffen.
## 2026-10-12 · Sitzung 4 · 12:50–13:35
**Issue:** #021–#025, #030
**Version:** 0.4.0
### Erledigt
- Zweites, getrenntes Paar SSH-Testziele (`sshkv-test-b`, Ports 2231/2232, Mock-OIDC 8091) für den Rotation/Widerruf-Agenten, damit Integrationstests sich nicht gegenseitig zurücksetzen
- #021 Abgleich mit reiner Befundlogik, Worker-Ablauf, Zeitplan; #022 Übernahme mit neuem Feld `ausserhalbEntfernen` – Integrationstests und E2E
- #023/#024 Rotation und Widerruf (Sub-Agent) mit Zwischenzustand `widerrufEingeleitetAm`; #025 Zuweisungsmatrix (Sub-Agent)
- #030 Gitea-Skript bereitgelegt (Trockenlauf läuft)
- Stand: 470 Unit-/DB-Tests, 38 Integrationstests, 21 E2E-Abläufe grün
### Entscheidungen
- **Abgleich ändert nichts auf dem Host:** Er meldet nur; Korrekturen laufen über Dry-Run und Ausrollen, damit ein Mensch sie sieht.
- **Bekannte Schlüssel außerhalb des Blocks sind kein Befund** – nur unbekannte. Sonst meldete jeder bewusst stehen gelassene Admin-Key dauerhaft Drift.
- **Widerruf mit Zwischenzustand:** Ein Schlüssel wird erst WIDERRUFEN, wenn er überall entfernt ist; ein nicht erreichbarer Host lässt ihn erkennbar „unvollständig“.
- **Rotation entfernt den alten Schlüssel erst nach erfolgreichen Verbindungstests**; doppelte Aliase in `~/.ssh/config`: die jüngste Zuweisung gewinnt.
- **Migrationszeitstempel in Ortszeit-Reihenfolge** auch bei parallel entstandenen Migrationen (`…130000` Agent, `…131000` Hauptsitzung).
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 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: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 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

+10 -5
View File
@@ -1,20 +1,25 @@
# #018 Zielbenutzer anlegen und sudoers
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.3.0
**Labels:** backend, sicherheit
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 0 h 30 min
## Ziel
Benutzer (z. B. `deploy`) anlegen, optional sudoers mit Vorschau und `visudo -c`.
## Aufgaben
- [ ] Auswahl keine/bestimmte Befehle/voll
- [ ] Entfernen beim Widerruf
- [x] Auswahl keine/bestimmte Befehle/voll
- [x] Entfernen beim Widerruf
## Akzeptanzkriterien
- [ ] Integrationstest
- [x] Integrationstest
## Abhängigkeiten
#016
## Notizen / Entscheidungen
- **sudoers** entsteht als `/etc/sudoers.d/.sshkv.XXXX` (Punkt im Namen: sudo ignoriert die Datei), wird mit `visudo -cf` geprüft und erst dann nach `/etc/sudoers.d/sshkv-<benutzer>` verschoben. Kopfzeile kennzeichnet sshkv-Dateien; eine fremde Datei gleichen Namens wird nie überschrieben.
- **Befehle:** absoluter Pfad, keine Zeichen mit Sonderbedeutung in sudoers (`, : = \`, Wildcards) – die Liste soll wirken, wie sie aussieht.
- **Geschützte Konten** (root, sshkv, Systemkonten) verwaltet sshkv nie als Zielkonto.
- **Herkunft gemerkt:** `vonSshkvAngelegt` – nur dann löscht ein Entfernen den Benutzer samt Home, sonst nur die sudoers-Datei.
- Vorgänge `ZIELKONTO_ANLEGEN`/`ZIELKONTO_ENTFERNEN` (Migration `20261012110000`), Recht `zielkontenVerwalten` = Admin mit frischer Bestätigung. Oberfläche im Assistenten (#020) mit sudoers-Vorschau.
+16 -3
View File
@@ -1,5 +1,5 @@
# #019 Vorlagen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.3.0
**Labels:** backend, ui
**Geschätzt:** 2 h **Tatsächlich:** –
@@ -8,12 +8,25 @@
Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen.
## Aufgaben
- [ ] Platzhalter `{{quellHostIp}}` usw.
- [x] Platzhalter `{{quellHostIp}}` usw.
- [x] Oberfläche: Liste `/vorlagen` (alle lesen), Anlegen/Bearbeiten/Archivieren nur Admin
- [x] Live-Vorschau der aufgelösten Optionen, des Alias und der sudoers-Datei
## Akzeptanzkriterien
- [ ] Vorlage füllt Assistent vor
- [ ] Vorlage füllt Assistent vor (folgt mit dem Assistenten, #020)
## Abhängigkeiten
#017, #018
## Notizen / Entscheidungen
- Vorlagen pflegt nur, wer `einstellungenVerwalten` darf (Admin); lesen darf jede Rolle, weil
Vorlagen keine Geheimnisse enthalten und Operatoren sie im Assistenten sehen.
- Die Vorschau im Formular ruft dieselben reinen Funktionen wie der Server
(`platzhalterErsetzen`, `optionenPruefen`, `sudoersInhalt`) mit eigenen Beispielwerten auf;
Fehler werden dort nur angezeigt, verbindlich prüft der Server beim Speichern.
- Das Befehlsfeld wird bei anderem sudo-Modus nur ausgeblendet, nicht entfernt – so gehen
eingegebene Befehle beim Umschalten nicht verloren; der Service verwirft sie ohnehin.
- Die sudoers-Vorschau bricht nicht um: sudoers ist zeilenbasiert, ein optischer Umbruch sähe
wie eine zweite Regel aus.
- Eingebaute Vorlagen lassen sich bearbeiten, aber nicht archivieren; ihre Bearbeiten-Seite
zeigt deshalb keinen Archivieren-Knopf.
+12 -6
View File
@@ -1,21 +1,27 @@
# #020 Host-zu-Host-Assistent und Verbindungstest
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.3.0
**Labels:** ui, backend
**Geschätzt:** 4 h **Tatsächlich:** –
**Geschätzt:** 4 h **Tatsächlich:** 0 h 50 min
## Ziel
Geführter Ablauf: Schlüssel erzeugen, Private auf A, Public auf B, Host-Key von B in known_hosts von A, Verbindungstest.
## Aufgaben
- [ ] Schrittanzeige
- [ ] Zusammenfassung + Dry-Run
- [ ] `ssh -o BatchMode=yes … true` auf A
- [x] Schrittanzeige
- [x] Zusammenfassung + Dry-Run
- [x] `ssh -o BatchMode=yes … true` auf A
## Akzeptanzkriterien
- [ ] Kern-Anwendungsfall Ende-zu-Ende gegen zwei Test-Container
- [x] Kern-Anwendungsfall Ende-zu-Ende gegen zwei Test-Container
## Abhängigkeiten
#019
## Notizen / Entscheidungen
- **Fünf Schritte:** Vorlage & Zweck, Quelle, Ziel (Zielbenutzer, optional anlegen mit sudo, Einschränkungen, Alias, HostName/Port aus Sicht von A, Fingerprint von B bestätigen), Zusammenfassung, Ausrollen (Dry-Run, anwenden, Verbindungstest).
- **Vorlage** füllt Zielbenutzer, Anlegen/sudo, Ablaufdatum, `from=`-Muster und Alias vor; Platzhalter werden mit A und B aufgelöst. Ist die Adresse von A keine IP, fragt der Assistent nach.
- **Wiederaufnehmbar:** Erledigte Schritte (Schlüssel, Zielkonto, Zuweisungen) werden beim „Fortsetzen“ übersprungen. Braucht das Zielkonto eine frische Bestätigung, geschieht sie in einem neuen Tab – die Eingaben bleiben stehen.
- **root als Ziel:** deutlicher Hinweis, dass ein Agent wie Claude Code dann uneingeschränkten Zugriff hätte.
- **Verbindungstest:** `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes <alias> true` auf A als Zielbenutzer.
- E2E: Vorlage „Deployment-Zugang“ → `deploy` auf B angelegt → A meldet sich als deploy bei B an.
+12 -5
View File
@@ -1,20 +1,27 @@
# #021 Abgleich und Drift-Erkennung
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.4.0
**Labels:** backend
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 0 h 45 min
## Ziel
Periodischer und manueller Abgleich, Befunde je Host/Benutzer.
## Aufgaben
- [ ] Zeitplan konfigurierbar
- [ ] Befundarten
- [x] Zeitplan konfigurierbar
- [x] Befundarten
## Akzeptanzkriterien
- [ ] Integrationstest
- [x] Integrationstest
## Abhängigkeiten
#016
## 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
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.4.0
**Labels:** backend, ui
**Geschätzt:** 2 h **Tatsächlich:** –
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
## Ziel
Unbekannte Schlüssel eines Hosts übernehmen und zuordnen.
## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert
- [x] wird zu Beginn der Phase verfeinert
## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert
- [x] wird zu Beginn der Phase verfeinert
## Abhängigkeiten
#021
## 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
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.4.0
**Labels:** backend, ui
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 0 h 20 min
## Ziel
Neuer Schlüssel an allen Verwendungen, Test, alter entfernt.
## 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
- [ ] 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
#020
#020, #024
## 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
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.4.0
**Labels:** backend, ui, sicherheit
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
## Ziel
Ein-Klick-Widerruf inkl. Benutzer/sudoers; Massenwiderruf mit Re-Auth.
## 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
- [ ] 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
#018
## 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
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.4.0
**Labels:** ui
**Geschätzt:** 2 h **Tatsächlich:** –
**Geschätzt:** 2 h **Tatsächlich:** 0,5 h
## Ziel
Matrix Schlüssel × Host.
## 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
- [ ] 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
#016
## 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",
"version": "0.2.0",
"version": "0.4.0",
"private": true,
"type": "module",
"description": "SSHKeyVault – zentrale Verwaltung von SSH-Schluesseln mit Weboberflaeche",
@@ -0,0 +1,10 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ANLEGEN';
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ENTFERNEN';
@@ -0,0 +1,3 @@
-- #024: Zwischenzustand „Widerruf eingeleitet“. Der Schluessel bleibt AKTIV (bzw. ABGELAUFEN),
-- bis alle Verwendungen nachweislich entfernt sind; erst dann wird er WIDERRUFEN bzw. ROTIERT.
ALTER TABLE "Schluessel" ADD COLUMN "widerrufEingeleitetAm" TIMESTAMP(3);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "PublicVerwendung" ADD COLUMN "ausserhalbEntfernen" BOOLEAN NOT NULL DEFAULT false;
+40 -30
View File
@@ -110,6 +110,10 @@ enum VorgangsArt {
SCHLUESSEL_IMPORTIEREN
/// Gespeicherten Private Key endgueltig verwerfen (#014).
PRIVATE_KEY_LOESCHEN
/// Zielbenutzer anlegen und sudoers setzen (#018).
ZIELKONTO_ANLEGEN
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
ZIELKONTO_ENTFERNEN
}
enum VorgangsStatus {
@@ -270,44 +274,47 @@ model HostSchluessel {
// ---------------------------------------------------------------- Schluessel
model Schluessel {
id String @id @default(cuid())
id String @id @default(cuid())
/// Zweck, z. B. „Backup srv-backup01 → srv-pve01“.
name String
name String
/// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01.
slug String @unique
typ SchluesselTyp
kategorie SchluesselKategorie
status SchluesselStatus @default(AKTIV)
slug String @unique
typ SchluesselTyp
kategorie SchluesselKategorie
status SchluesselStatus @default(AKTIV)
/// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar".
publicKey String
fingerprint String @unique
kommentar String
publicKey String
fingerprint String @unique
kommentar String
/// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts.
privatVerschluesselt Bytes?
privatVerschluesselt Bytes?
/// Data-Key, verschluesselt mit dem Master-Key.
dekVerschluesselt Bytes?
masterKeyVersion Int?
privatGeloeschtAm DateTime?
passphraseGeschuetzt Boolean @default(false)
dekVerschluesselt Bytes?
masterKeyVersion Int?
privatGeloeschtAm DateTime?
passphraseGeschuetzt Boolean @default(false)
/// Genau ein Deployment-Key ist Instanz-Standard (partieller Index).
instanzStandard Boolean @default(false)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
vorlageId String?
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
tags String[]
ablaufAm DateTime?
zuletztVerwendetAm DateTime?
instanzStandard Boolean @default(false)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
vorlageId String?
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
tags String[]
ablaufAm 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.
vorgaengerId String? @unique
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
nachfolger Schluessel? @relation("Rotation")
erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
vorgaengerId String? @unique
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
nachfolger Schluessel? @relation("Rotation")
erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
selbstHinterlegt Boolean @default(false)
angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt
selbstHinterlegt Boolean @default(false)
angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt
publicVerwendungen PublicVerwendung[]
privateVerwendungen PrivateVerwendung[]
@@ -329,6 +336,9 @@ model PublicVerwendung {
zielbenutzer String
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
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)
zuletztAngewendetAm DateTime?
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);
});
+10
View File
@@ -39,6 +39,16 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
{ name: "hosts", pfad: "/hosts" },
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
// Matrix Schluessel × Host (#025): die Seite darf auf 390 px nicht seitwaerts scrollen –
// nur der Matrix-Container. „belegt=1“, damit das Bild Zellen zeigt statt leerer Zeilen.
{ name: "zuweisungen", pfad: "/zuweisungen?belegt=1" },
{ name: "vorlagen", pfad: "/vorlagen" },
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
{
name: "vorlage-bearbeiten",
pfad: "/vorlagen",
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
},
];
const ANSICHTEN = [
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Bestätigt" };
/** Ziel der Bestaetigung aus dem Assistenten (#020): der Assistent wartet im anderen Tab. */
export default async function BestaetigtSeite() {
await sitzungErforderlich();
return (
<Leerzustand
titel="Anmeldung bestätigt"
text="Die Bestätigung gilt fünf Minuten. Diesen Tab schließen und im Assistenten „Fortsetzen“ wählen."
/>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { Assistent } from "@/components/assistent/Assistent";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { assistentDaten } from "@/server/queries/assistent";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Host-zu-Host-Assistent" };
/**
* Host-zu-Host-Assistent (#020): ein Schluesselpaar fuer genau einen Zweck, Private Key auf
* Host A, Public Key auf Host B, Verbindungstest. Nur Admins – es wird ein Private Key ausgerollt.
*/
export default async function AssistentSeite() {
const { benutzer } = await sitzungErforderlich("privateKeyAusrollen", "/assistent");
const daten = await assistentDaten();
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Übersicht", href: "/" }, { titel: "Host-zu-Host-Assistent" }]} />}
titel="Host-zu-Host-Assistent"
untertitel="Host A soll sich per SSH an Host B anmelden – Schlüssel, Ausrollen und Test in einem Durchgang."
/>
<Assistent daten={daten} bestaetigt={bestaetigungFrisch(benutzer.bestaetigtAm)} />
</div>
);
}
+13
View File
@@ -18,7 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
import { BootstrapAktionen } from "./BootstrapAktionen";
import { sshdHinweise } from "@/lib/sshd-hinweise";
import { AbgleichKarten } from "@/components/abgleich/AbgleichKarten";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { istZustand } from "@/server/queries/abgleich";
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
import { verwendungenAufHost } from "@/server/queries/ziele";
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 darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
const verwendungen = await verwendungenAufHost(host.id);
const ist = await istZustand(host.id);
const jetzt = new Date();
return (
@@ -295,6 +298,16 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
)}
</Karte>
{host.bootstrapAm && (
<AbgleichKarten
hostId={host.id}
zustand={ist}
darfAbgleichen={verwalten}
darfUebernehmen={darf(benutzer.rolle, "schluesselVerwalten") && !archiviert}
darfVerwalten={darfAusrollen}
/>
)}
{verwalten && (
<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">
+4 -3
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 Link from "next/link";
import {
@@ -10,6 +10,7 @@ import {
letzterAbgleich,
type AktionsArt,
} from "@/beispiel/uebersicht";
import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Karte } from "@/components/ui/Karte";
@@ -52,8 +53,8 @@ export default async function Uebersicht() {
}
aktionen={
<>
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
<Button href="/zuweisungen" icon={<Route className="size-4" />}>
<AlleAbgleichenKnopf />
<Button href="/assistent" icon={<Route className="size-4" />}>
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 Link from "next/link";
import { notFound } from "next/navigation";
@@ -15,10 +15,15 @@ import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
import { datum, datumZeit } from "@/lib/format";
import { darf } from "@/server/auth/rechte";
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 { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
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 { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
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. */
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 { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
const s = await schluesselDetail(id);
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 = {
public: darf(benutzer.rolle, "publicKeyAusrollen"),
privat: darf(benutzer.rolle, "privateKeyAusrollen"),
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 personenKey = s.kategorie === "PERSON";
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 darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
const jetzt = new Date();
return (
<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} />
<KategorieBadge kategorie={s.kategorie} />
<StatusBadge status={s.status} />
{imWiderruf && <Badge variante="gefahr">Widerruf unvollständig</Badge>}
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
@@ -240,14 +288,99 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
</>
}
aktionen={
verwalten && (
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
Bearbeiten
</Button>
(verwalten || rotierbar || (widerrufbar && widerruf)) && (
<>
{verwalten && (
<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">
<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">
@@ -321,6 +454,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
</div>
{rechte.public &&
!imWiderruf &&
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
<Karte
titel="Dry-Run-Vorschau"
@@ -336,6 +470,42 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
</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
titel="Public Key"
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 Link from "next/link";
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { AlleAuswaehlen } from "@/components/widerruf/AlleAuswaehlen";
import { Auswahlliste, Feld } from "@/components/ui/Feld";
import { Karte } from "@/components/ui/Karte";
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. */
export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) {
// 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 liste = await schluesselListe({ q, kategorie, status });
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 jetzt = new Date();
@@ -142,13 +171,27 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
)
) : (
<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. */}
<ul className="flex flex-col md:hidden">
{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
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="truncate">{s.name}</span>
@@ -161,6 +204,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
<TypBadge typ={s.typ} publicKey={s.publicKey} />
<KategorieBadge kategorie={s.kategorie} />
<StatusBadge status={s.status} />
<WiderrufBadge s={s} />
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
</span>
<span className="text-muted text-[13px]">
@@ -175,6 +219,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
className="hidden md:block"
beschriftung="Schlüssel"
spalten={[
...(massen ? [{ titel: "Auswahl", versteckt: true, className: "w-10" }] : []),
{ titel: "Name" },
{ titel: "Typ" },
{ titel: "Kategorie" },
@@ -185,6 +230,11 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
>
{liste.map((s) => (
<TabellenZeile key={s.id}>
{massen && (
<TabellenZelle className="w-10">
<Auswahl s={s} />
</TabellenZelle>
)}
<TabellenZelle>
<span className="flex min-w-0 items-center gap-2">
<Link
@@ -195,6 +245,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
</Link>
<PrivatHinweis s={s} />
<StatusBadge status={s.status} />
<WiderrufBadge s={s} />
</span>
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
</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>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { VorlageArchivierenDialog } from "@/components/vorlagen/VorlageArchivierenDialog";
import { VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { vorlageDetail } from "@/server/queries/vorlagen";
export const metadata: Metadata = { title: "Vorlage bearbeiten" };
export default async function VorlageBearbeiten({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await sitzungErforderlich("einstellungenVerwalten", `/vorlagen/${id}`);
const v = await vorlageDetail(id);
// Archivierte Vorlagen sind fuer die Oberflaeche weg; das Protokoll kennt sie noch (#019).
if (!v || v.archiviertAm) notFound();
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: v.name }]} />}
titel={`${v.name} bearbeiten`}
badges={v.eingebaut ? <Badge>eingebaut</Badge> : undefined}
untertitel={
v.eingebaut
? "Eingebaute Vorlagen lassen sich anpassen, aber nicht archivieren. Ein späterer Seed überschreibt Ihre Änderungen nicht."
: undefined
}
aktionen={!v.eingebaut && <VorlageArchivierenDialog vorlageId={v.id} name={v.name} />}
/>
<VorlagenFormular
vorgabe={{
id: v.id,
name: v.name,
beschreibung: v.beschreibung ?? "",
// Das Formular kennt nur, was Vorlagen erzeugen; ANDERER/DEPLOYMENT kommen hier nicht vor.
schluesselTyp: v.schluesselTyp === "RSA4096" ? "RSA4096" : "ED25519",
kategorie: v.kategorie === "PERSON" ? "PERSON" : "DIENST",
standardLaufzeitTage: v.standardLaufzeitTage,
zielbenutzer: v.zielbenutzer,
zielbenutzerAnlegen: v.zielbenutzerAnlegen,
sudoModus: v.sudoModus,
sudoBefehle: v.sudoBefehle,
authorizedKeysOptionen: v.authorizedKeysOptionen ?? "",
sshConfigAlias: v.sshConfigAlias ?? "",
knownHostsEintragen: v.knownHostsEintragen,
privatNachAusrollenLoeschen: v.privatNachAusrollenLoeschen,
}}
/>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { LEERE_VORLAGE, VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Vorlage anlegen" };
export default async function VorlageNeu() {
// Vorlagen pflegen nur Admins (#019) – wie die uebrigen Einstellungen.
await sitzungErforderlich("einstellungenVerwalten", "/vorlagen/neu");
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: "Neue Vorlage" }]} />}
titel="Vorlage anlegen"
untertitel="Eine Vorlage füllt den Assistenten vor; im Einzelfall lässt sich alles noch ändern."
/>
<VorlagenFormular vorgabe={LEERE_VORLAGE} />
</div>
);
}
+129 -10
View File
@@ -1,18 +1,137 @@
import { FileStack, Pencil, Plus } from "lucide-react";
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { Fragment, type ReactNode } from "react";
import { KategorieBadge } from "@/components/schluessel/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { vorlagenListe, type VorlagenListenEintrag } from "@/server/queries/vorlagen";
export const metadata: Metadata = { title: "Vorlagen" };
export default async function Vorlagen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
const SUDO_TEXT = { KEINER: null, BEFEHLE: "sudo: bestimmte Befehle", VOLL: "sudo: voll" } as const;
function Angabe({ titel, children }: { titel: string; children: ReactNode }) {
return (
<Platzhalterseite
titel="Vorlagen"
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup"
phase={3}
issue="#019"
/>
<div className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted text-[12.5px] font-medium">{titel}</dt>
<dd className="min-w-0 text-[14px]">{children}</dd>
</div>
);
}
function Mono({ wert }: { wert: string | null }) {
if (!wert) return <span className="text-muted">–</span>;
// Lange Optionslisten sollen auf 390 px nach einem Komma umbrechen, nicht mitten im Wort (#019).
const teile = wert.split(",");
return (
<code className="text-text font-mono text-[13px] [overflow-wrap:anywhere]">
{teile.map((teil, i) => (
<Fragment key={i}>
{teil}
{i < teile.length - 1 && (
<>
,<wbr />
</>
)}
</Fragment>
))}
</code>
);
}
function VorlagenKarte({ v, verwalten }: { v: VorlagenListenEintrag; verwalten: boolean }) {
const sudo = SUDO_TEXT[v.sudoModus];
return (
<Karte
as="article"
titel={v.name}
aktionen={
verwalten && (
<Button
href={`/vorlagen/${v.id}`}
klein
icon={<Pencil className="size-3.5" />}
aria-label={`${v.name} bearbeiten`}
>
Bearbeiten
</Button>
)
}
className="flex flex-col"
>
<div className="-mt-2 mb-4 flex flex-wrap items-center gap-1.5">
<KategorieBadge kategorie={v.kategorie} />
<Badge mono>{v.schluesselTyp === "RSA4096" ? "rsa-4096" : "ed25519"}</Badge>
{v.eingebaut && <Badge>eingebaut</Badge>}
{v.standardLaufzeitTage && (
<Badge variante="messing">
{v.standardLaufzeitTage === 1 ? "1 Tag" : `${v.standardLaufzeitTage} Tage`}
</Badge>
)}
</div>
{v.beschreibung && <p className="text-muted mb-4 text-[14px]">{v.beschreibung}</p>}
<dl className="border-border mt-auto grid gap-3 border-t pt-4">
<Angabe titel="Zielbenutzer">
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Mono wert={v.zielbenutzer} />
{v.zielbenutzerAnlegen && <span className="text-muted text-[13px]">wird angelegt</span>}
{sudo && (
<Badge variante={v.sudoModus === "VOLL" ? "gefahr" : "neutral"}>
{sudo}
{v.sudoModus === "BEFEHLE" && ` (${v.sudoBefehle.length})`}
</Badge>
)}
</span>
</Angabe>
<Angabe titel="authorized_keys-Optionen">
<Mono wert={v.authorizedKeysOptionen} />
</Angabe>
<Angabe titel="Alias-Muster">
<Mono wert={v.sshConfigAlias} />
</Angabe>
</dl>
</Karte>
);
}
/** Vorlagen (#019): lesen darf jeder, anlegen und bearbeiten nur, wer Einstellungen verwaltet. */
export default async function Vorlagen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen");
const vorlagen = await vorlagenListe();
const verwalten = darf(benutzer.rolle, "einstellungenVerwalten");
const anlegen = verwalten && (
<Button href="/vorlagen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
Vorlage anlegen
</Button>
);
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Vorlagen"
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup – sie füllen den Assistenten vor."
aktionen={anlegen}
/>
{vorlagen.length === 0 ? (
<Leerzustand
icon={<FileStack className="size-5" />}
titel="Keine Vorlagen"
text="Die eingebauten Vorlagen legt der Seed an (pnpm db:seed)."
aktion={anlegen}
/>
) : (
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{vorlagen.map((v) => (
<VorlagenKarte key={v.id} v={v} verwalten={verwalten} />
))}
</div>
)}
</div>
);
}
+155 -9
View File
@@ -1,18 +1,164 @@
import { Download, Network, Search } from "lucide-react";
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 { zielAuswahl } from "@/server/queries/ziele";
import { zuweisungsMatrix } from "@/server/queries/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).
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 (
<Platzhalterseite
titel="Zuweisungen"
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt"
phase={2}
issue="#016"
/>
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Zuweisungen"
untertitel="Welcher Schlüssel auf welchem Host verwaltet ist"
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>
</>
);
}
+605
View File
@@ -0,0 +1,605 @@
"use client";
import { ArrowLeft, ArrowRight, ExternalLink, Play } from "lucide-react";
import { useState } from "react";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { warten } from "@/components/vorgang/warten";
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
import {
assistentErzeugenAktion,
assistentZielkontoAktion,
assistentZuweisenAktion,
type SchrittAntwort,
} from "@/server/actions/assistent";
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
import type { AssistentDaten } from "@/server/queries/assistent";
/**
* Host-zu-Host-Assistent (#020). Vier Schritte Eingabe, dann eine Zusammenfassung, dann die
* Ausfuehrung: Schluessel erzeugen → (Zielbenutzer anlegen) → zuweisen → Dry-Run und Ausrollen →
* Verbindungstest. Die Ausfuehrung ist wiederaufnehmbar: was schon erledigt ist, wird nicht
* wiederholt – wichtig, wenn mittendrin eine erneute Bestaetigung noetig wird.
*/
type Werte = {
vorlageId: string;
zweck: string;
ablaufAm: string;
quelleHostId: string;
quelleBenutzer: string;
pfad: string;
zielHostId: string;
zielBenutzer: string;
anlegen: boolean;
sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
sudoBefehle: string;
optionen: string;
quellIp: string;
alias: string;
hostName: string;
port: string;
knownHosts: boolean;
fingerprintGeprueft: boolean;
};
const LEER: Werte = {
vorlageId: "",
zweck: "",
ablaufAm: "",
quelleHostId: "",
quelleBenutzer: "root",
pfad: "",
zielHostId: "",
zielBenutzer: "root",
anlegen: false,
sudoModus: "KEINER",
sudoBefehle: "",
optionen: "",
quellIp: "",
alias: "",
hostName: "",
port: "22",
knownHosts: true,
fingerprintGeprueft: false,
};
const SCHRITTE = ["Vorlage & Zweck", "Quelle (Host A)", "Ziel (Host B)", "Prüfen", "Ausrollen"] as const;
type Ablauf = {
schluesselId?: string;
zielkontoFertig?: boolean;
privatVerwendungId?: string;
laeuft?: string;
fehler?: string;
bestaetigen?: boolean;
};
function datumIn(tage: number): string {
return new Date(Date.now() + tage * 86_400_000).toISOString().slice(0, 10);
}
function kurz(text: string): string {
return (
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 40) || "zugang"
);
}
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
const [schritt, setSchritt] = useState(0);
const [w, setW] = useState<Werte>(LEER);
const [ablauf, setAblauf] = useState<Ablauf>({});
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => setW((alt) => ({ ...alt, [feld]: wert }));
const vorlage = daten.vorlagen.find((v) => v.id === w.vorlageId);
const quelle = daten.hosts.find((h) => h.id === w.quelleHostId);
const ziel = daten.hosts.find((h) => h.id === w.zielHostId);
function vorlageWaehlen(id: string) {
const v = daten.vorlagen.find((x) => x.id === id);
setW((alt) => ({
...alt,
vorlageId: id,
...(v
? {
zielBenutzer: v.zielbenutzer,
anlegen: v.zielbenutzerAnlegen,
sudoModus: v.sudoModus,
sudoBefehle: v.sudoBefehle.join("\n"),
knownHosts: v.knownHostsEintragen,
ablaufAm: v.standardLaufzeitTage ? datumIn(v.standardLaufzeitTage) : "",
}
: {}),
}));
}
/** Beim Wechsel zu „Ziel“: Muster der Vorlage mit A und B aufloesen (nur leere Felder). */
function zielVorbelegen(zielId: string) {
const b = daten.hosts.find((h) => h.id === zielId);
const quellIp = w.quellIp || (quelle && istIpAdresse(quelle.adresse) ? quelle.adresse : "");
const werte = {
quellHost: quelle?.name,
quellHostIp: quellIp,
zielHost: b?.name,
zielHostIp: b?.adresse,
};
const aufloesen = (muster: string | null | undefined) => {
if (!muster) return "";
try {
return platzhalterErsetzen(muster, werte);
} catch {
return muster;
}
};
setW((alt) => ({
...alt,
zielHostId: zielId,
quellIp,
optionen: alt.optionen || aufloesen(vorlage?.authorizedKeysOptionen),
alias: alt.alias || aufloesen(vorlage?.sshConfigAlias) || (b ? b.name : ""),
hostName: b ? b.adresse : "",
port: b ? String(b.port) : "22",
pfad: alt.pfad || `~/.ssh/id_ed25519_${kurz(alt.zweck)}`,
fingerprintGeprueft: false,
}));
}
const offenePlatzhalter = /\{\{/.test(w.optionen) || /\{\{/.test(w.alias);
let sudoVorschau: string | null = null;
let sudoFehler: string | undefined;
if (w.anlegen) {
try {
sudoVorschau = sudoersInhalt(
w.zielBenutzer,
w.sudoModus,
w.sudoBefehle.split("\n").filter((z) => z.trim()),
);
} catch (fehler) {
sudoFehler = (fehler as Error).message;
}
}
const schrittOk = [
w.zweck.trim().length > 0,
Boolean(w.quelleHostId && w.quelleBenutzer),
Boolean(w.zielHostId && w.zielHostId !== w.quelleHostId && w.zielBenutzer && w.alias && w.hostName) &&
!offenePlatzhalter &&
!sudoFehler &&
(!w.knownHosts || w.fingerprintGeprueft),
true,
];
async function ausfuehren() {
let stand = { ...ablauf, fehler: undefined, bestaetigen: false } as Ablauf;
const melden = (neu: Partial<Ablauf>) => {
stand = { ...stand, ...neu };
setAblauf(stand);
};
const pruefen = (antwort: SchrittAntwort) => {
if (antwort.fehler || antwort.bestaetigen) {
melden({ laeuft: undefined, fehler: antwort.fehler, bestaetigen: Boolean(antwort.bestaetigen) });
return false;
}
return true;
};
try {
if (!stand.schluesselId) {
melden({ laeuft: "Schlüsselpaar wird erzeugt …" });
const antwort = await assistentErzeugenAktion({
name: w.zweck.trim(),
vorlageId: w.vorlageId || null,
ablaufAm: w.ablaufAm || null,
});
if (!pruefen(antwort)) return;
const ergebnis = await warten(antwort.vorgangId!);
if (ergebnis.status !== "ERLEDIGT")
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Erzeugen fehlgeschlagen." });
melden({ schluesselId: (ergebnis.ergebnis as { schluesselId: string }).schluesselId });
}
if (w.anlegen && !stand.zielkontoFertig) {
melden({ laeuft: `Benutzer ${w.zielBenutzer} wird auf ${ziel?.name} eingerichtet …` });
const antwort = await assistentZielkontoAktion({
hostId: w.zielHostId,
benutzer: w.zielBenutzer,
modus: w.sudoModus,
befehle: w.sudoBefehle.split("\n").filter((z) => z.trim()),
});
if (!pruefen(antwort)) return;
const ergebnis = await warten(antwort.vorgangId!);
if (ergebnis.status !== "ERLEDIGT")
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Zielkonto fehlgeschlagen." });
melden({ zielkontoFertig: true });
}
if (!stand.privatVerwendungId) {
melden({ laeuft: "Zuweisungen werden angelegt …" });
const antwort = await assistentZuweisenAktion({
schluesselId: stand.schluesselId!,
quelle: { hostId: w.quelleHostId, benutzer: w.quelleBenutzer, pfad: w.pfad },
ziel: { hostId: w.zielHostId, benutzer: w.zielBenutzer, optionen: w.optionen.trim() || null },
ssh: { alias: w.alias, hostName: w.hostName, port: Number(w.port), knownHosts: w.knownHosts },
});
if (!pruefen(antwort)) return;
melden({ privatVerwendungId: antwort.privatVerwendungId });
}
melden({ laeuft: undefined });
setSchritt(4);
} catch (fehler) {
melden({ laeuft: undefined, fehler: (fehler as Error).message });
}
}
return (
<div className="flex flex-col gap-6">
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} />
{schritt === 0 && (
<Karte
titel="Vorlage & Zweck"
untertitel="Eine Vorlage füllt Zielbenutzer, Einschränkungen und Ablauf vor."
>
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Vorlage"
name="vorlageId"
value={w.vorlageId}
onChange={(e) => vorlageWaehlen(e.target.value)}
>
<option value="">– ohne Vorlage –</option>
{daten.vorlagen.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</Auswahlliste>
{vorlage?.beschreibung && <p className="text-muted text-[14px]">{vorlage.beschreibung}</p>}
<Feld
beschriftung="Zweck"
name="zweck"
value={w.zweck}
onChange={(e) => setze("zweck", e.target.value)}
placeholder="Backup srv-backup01 → srv-pve01"
required
/>
<Feld
beschriftung="Ablaufdatum (optional)"
name="ablaufAm"
type="date"
value={w.ablaufAm}
onChange={(e) => setze("ablaufAm", e.target.value)}
hinweis="Projektbezogene Zugänge sollten ablaufen."
/>
</div>
</Karte>
)}
{schritt === 1 && (
<Karte
titel="Quelle – Host A"
untertitel="Dieser Host bekommt den Private Key und meldet sich später an."
>
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Host A"
name="quelleHostId"
value={w.quelleHostId}
onChange={(e) => setze("quelleHostId", e.target.value)}
>
<option value="">Host wählen …</option>
{daten.hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Auswahlliste>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Benutzer auf A"
name="quelleBenutzer"
value={w.quelleBenutzer}
onChange={(e) => setze("quelleBenutzer", e.target.value)}
mono
/>
<Feld
beschriftung="Pfad des Private Keys"
name="pfad"
value={w.pfad || `~/.ssh/id_ed25519_${kurz(w.zweck)}`}
onChange={(e) => setze("pfad", e.target.value)}
mono
/>
</div>
</div>
</Karte>
)}
{schritt === 2 && (
<Karte titel="Ziel – Host B" untertitel="Dieser Host nimmt den Public Key an.">
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Host B"
name="zielHostId"
value={w.zielHostId}
onChange={(e) => zielVorbelegen(e.target.value)}
>
<option value="">Host wählen …</option>
{daten.hosts
.filter((h) => h.id !== w.quelleHostId)
.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Auswahlliste>
<Feld
beschriftung="Benutzer auf B"
name="zielBenutzer"
value={w.zielBenutzer}
onChange={(e) => setze("zielBenutzer", e.target.value)}
mono
/>
{w.zielBenutzer === "root" && (
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[13.5px]">
▲ Zugang als root: Wer den Private Key auf A nutzt – etwa ein KI-Agent wie Claude Code –, hat
auf B uneingeschränkten Zugriff. Für Deployments ist ein eigener Benutzer wie „deploy“ besser.
</p>
)}
<Ankreuzfeld
beschriftung={`Benutzer „${w.zielBenutzer}“ auf B anlegen bzw. pflegen`}
erklaerung="Mit Home und ~/.ssh; optional mit sudoers-Datei. Verlangt eine frische Bestätigung."
checked={w.anlegen}
onChange={(e) => setze("anlegen", e.target.checked)}
/>
{w.anlegen && (
<div className="border-border flex flex-col gap-3 border-l-2 pl-4">
<Auswahlliste
beschriftung="sudo"
name="sudoModus"
value={w.sudoModus}
onChange={(e) => setze("sudoModus", e.target.value as Werte["sudoModus"])}
>
<option value="KEINER">keine sudo-Rechte</option>
<option value="BEFEHLE">bestimmte Befehle</option>
<option value="VOLL">voller sudo-Zugriff</option>
</Auswahlliste>
{w.sudoModus === "BEFEHLE" && (
<Textbereich
beschriftung="Erlaubte Befehle (einer je Zeile, absoluter Pfad)"
name="sudoBefehle"
value={w.sudoBefehle}
onChange={(e) => setze("sudoBefehle", e.target.value)}
rows={3}
/>
)}
{sudoFehler && <p className="text-danger text-[13px]">{sudoFehler}</p>}
{sudoVorschau && (
<pre className="border-border bg-bg rounded-box overflow-x-auto border p-3 font-mono text-[12.5px]">
{sudoVorschau}
</pre>
)}
</div>
)}
{quelle && !istIpAdresse(quelle.adresse) && (
<Feld
beschriftung={`IP von ${quelle.name} (für from="…")`}
name="quellIp"
value={w.quellIp}
onChange={(e) => setze("quellIp", e.target.value)}
mono
hinweis="Die Adresse, unter der B den Host A sieht."
/>
)}
<Feld
beschriftung="Einschränkungen in authorized_keys"
name="optionen"
value={w.optionen}
onChange={(e) => setze("optionen", e.target.value)}
mono
placeholder={
quelle && istIpAdresse(quelle.adresse) ? `from="${quelle.adresse}"` : 'from="10.0.0.5"'
}
hinweis={
offenePlatzhalter
? "Es sind noch Platzhalter offen."
: 'Empfohlen: from="<IP von A>", damit der Schlüssel nur von dort funktioniert.'
}
/>
<div className="grid gap-4 sm:grid-cols-3">
<Feld
beschriftung="Alias auf A"
name="alias"
value={w.alias}
onChange={(e) => setze("alias", e.target.value)}
mono
/>
<Feld
beschriftung="HostName (aus Sicht von A)"
name="hostName"
value={w.hostName}
onChange={(e) => setze("hostName", e.target.value)}
mono
/>
<Feld
beschriftung="Port"
name="port"
type="number"
value={w.port}
onChange={(e) => setze("port", e.target.value)}
mono
/>
</div>
<Ankreuzfeld
beschriftung="Host-Key von B in known_hosts von A eintragen"
checked={w.knownHosts}
onChange={(e) => setze("knownHosts", e.target.checked)}
/>
{w.knownHosts && ziel && (
<div className="border-border rounded-box flex flex-col gap-2 border p-3">
{ziel.hostKeys.map((k) => (
<p key={k.fingerprint} className="font-mono text-[12.5px] break-all">
<span className="text-muted">{k.algorithmus}</span> {k.fingerprint}
</p>
))}
<Ankreuzfeld
beschriftung="Fingerprint von B ist bestätigt"
erklaerung="Derselbe Host-Key, dem sshkv beim Bootstrap von B vertraut hat."
checked={w.fingerprintGeprueft}
onChange={(e) => setze("fingerprintGeprueft", e.target.checked)}
/>
</div>
)}
</div>
</Karte>
)}
{schritt === 3 && (
<Karte
titel="Zusammenfassung"
untertitel="Noch ist nichts passiert. Ausgerollt wird erst nach dem Dry-Run."
>
<dl className="grid gap-x-6 gap-y-3 text-[14px] sm:grid-cols-[12rem_minmax(0,1fr)]">
<dt className="text-muted">Schlüssel</dt>
<dd>
ed25519 · {w.zweck}
{w.ablaufAm && (
<span className="text-accent">
{" "}
· läuft ab am {w.ablaufAm.split("-").reverse().join(".")}
</span>
)}
</dd>
<dt className="text-muted">Private Key</dt>
<dd className="font-mono text-[13px] break-all">
{w.quelleBenutzer}@{quelle?.name}:{w.pfad}
</dd>
<dt className="text-muted">~/.ssh/config auf A</dt>
<dd className="font-mono text-[13px] break-all">
Host {w.alias} → {w.zielBenutzer}@{w.hostName}:{w.port}
</dd>
<dt className="text-muted">Public Key</dt>
<dd className="font-mono text-[13px] break-all">
{w.optionen && `${w.optionen} · `}
{w.zielBenutzer}@{ziel?.name}
</dd>
{w.anlegen && (
<>
<dt className="text-muted">Zielbenutzer</dt>
<dd>
„{w.zielBenutzer}“ auf {ziel?.name} anlegen,{" "}
{w.sudoModus === "KEINER" ? (
"ohne sudo"
) : w.sudoModus === "VOLL" ? (
<span className="text-danger">voller sudo-Zugriff</span>
) : (
"sudo für bestimmte Befehle"
)}
</dd>
</>
)}
</dl>
{w.anlegen && !bestaetigt && !ablauf.bestaetigen && (
<p className="text-accent mt-4 text-[13.5px]">
Das Anlegen des Benutzers verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
Ausführen danach.
</p>
)}
<div className="border-border mt-5 flex flex-col gap-3 border-t pt-4">
<Formularfehler text={ablauf.fehler} />
{ablauf.bestaetigen && (
<a
href="/bestaetigen?ziel=%2Fassistent%2Fbestaetigt"
target="_blank"
rel="noopener"
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
>
<ExternalLink className="size-4" aria-hidden /> Anmeldung in neuem Tab bestätigen, dann hier
erneut ausführen
</a>
)}
{ablauf.laeuft && (
<p role="status" className="text-muted text-[14px]">
{ablauf.laeuft}
</p>
)}
<Button
variante="primaer"
icon={<Play className="size-4" />}
disabled={Boolean(ablauf.laeuft)}
onClick={() => void ausfuehren()}
>
{ablauf.schluesselId ? "Fortsetzen" : "Vorbereiten"}
</Button>
</div>
</Karte>
)}
{schritt === 4 && ablauf.schluesselId && (
<>
<Karte
titel="Dry-Run und Ausrollen"
untertitel="Prüfen Sie die Änderungen auf A und B, dann anwenden."
>
<AusrollenPanel auswahl={{ schluesselId: ablauf.schluesselId }} offen={2} />
</Karte>
<Karte
titel="Verbindung testen"
untertitel={`Auf ${quelle?.name} als ${w.quelleBenutzer}: ssh ${w.alias} true`}
>
{ablauf.privatVerwendungId && (
<VorgangsKnopf
aktion={verbindungTestenAktion.bind(null, ablauf.privatVerwendungId)}
beschriftung="Verbindung von A nach B testen"
variante="primaer"
/>
)}
<p className="text-muted mt-3 text-[13.5px]">
Zum Schlüssel:{" "}
<a className="text-accent" href={`/schluessel/${ablauf.schluesselId}`}>
{w.zweck}
</a>
</p>
</Karte>
</>
)}
{schritt < 3 && (
<div className="flex flex-wrap justify-between gap-3">
<Button
icon={<ArrowLeft className="size-4" />}
disabled={schritt === 0}
onClick={() => setSchritt(schritt - 1)}
>
Zurück
</Button>
<Button
variante="primaer"
icon={<ArrowRight className="size-4" />}
disabled={!schrittOk[schritt]}
onClick={() => {
if (schritt === 1 && !w.pfad) setze("pfad", `~/.ssh/id_ed25519_${kurz(w.zweck)}`);
setSchritt(schritt + 1);
}}
>
Weiter
</Button>
</div>
)}
{schritt === 3 && !ablauf.schluesselId && (
<div>
<Button
icon={<ArrowLeft className="size-4" />}
disabled={Boolean(ablauf.laeuft)}
onClick={() => setSchritt(2)}
>
Zurück
</Button>
</div>
)}
</div>
);
}
+29 -6
View File
@@ -2,7 +2,7 @@
import { Play, ScanSearch } from "lucide-react";
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 { DiffBox } from "@/components/ui/DiffBox";
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
@@ -34,7 +34,16 @@ type PlanZiel = {
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 [trockenlaufId, setTrockenlaufId] = useState<string>();
const [ausrollenId, setAusrollenId] = useState<string>();
@@ -43,12 +52,22 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
const trockenlauf = useVorgang(trockenlaufId);
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(() => {
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]);
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
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 fertig = ausrollen.stand?.status === "ERLEDIGT";
@@ -103,7 +122,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
</div>
))}
{plan && aenderungen > 0 && !fertig && (
{plan && (aenderungen > 0 || nurZustand) && !fertig && (
<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]">
<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 disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
Verwerfen
@@ -132,7 +155,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
</div>
</div>
)}
{plan && aenderungen === 0 && (
{plan && aenderungen === 0 && !fertig && (
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
)}
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && (
@@ -14,10 +14,13 @@ export function PublicZuweisenFormular({
schluesselId,
auswahl,
beiErfolg,
vorauswahl,
}: {
schluesselId: string;
auswahl: ZielAuswahl;
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 ergebnis = await publicZuweisenAktion(vorher, formular);
@@ -28,7 +31,7 @@ export function PublicZuweisenFormular({
<form onSubmit={absenden} className="flex flex-col gap-4">
<input type="hidden" name="schluesselId" value={schluesselId} />
<Formularfehler text={zustand.fehler} />
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue="" required>
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue={vorauswahl ?? ""} required>
<option value="" disabled>
Host oder Hostgruppe wählen …
</option>
@@ -2,7 +2,7 @@ import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums";
import { clsx } from "clsx";
/** 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: "○" },
AUSGEROLLT: { text: "synchron", klasse: "text-ok", 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 }) {
const anzeige = ANZEIGE[zustand];
const anzeige = ZUSTAND_ANZEIGE[zustand];
return (
<span
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,48 @@
"use client";
import { Archive } from "lucide-react";
import { useActionState, useRef } from "react";
import { Button, Formularfehler } from "@/components/ui";
import type { FormularZustand } from "@/server/actions/formular";
import { vorlageArchivierenAktion } from "@/server/actions/vorlagen";
/** Archivieren mit Rueckfrage (#019), gleiches Muster wie bei Hosts (#008). */
export function VorlageArchivierenDialog({ vorlageId, name }: { vorlageId: string; name: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(vorlageArchivierenAktion, {});
return (
<>
<Button
variante="gefahr"
icon={<Archive className="size-4" />}
onClick={() => dialog.current?.showModal()}
>
Vorlage archivieren
</Button>
<dialog
ref={dialog}
aria-labelledby="archivieren-titel"
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
>
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="id" value={vorlageId} />
<h2 id="archivieren-titel" className="text-[17px] font-semibold">
„{name}“ archivieren?
</h2>
<p className="text-muted text-[14.5px]">
Die Vorlage steht im Assistenten nicht mehr zur Auswahl. Schlüssel, die mit ihr angelegt wurden,
bleiben unverändert; das Protokoll bleibt erhalten.
</p>
<Formularfehler text={zustand.fehler} />
<div className="flex flex-wrap justify-end gap-2.5">
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
<Button type="submit" variante="gefahr" disabled={laeuft}>
{laeuft ? "Archiviere …" : "Archivieren"}
</Button>
</div>
</form>
</dialog>
</>
);
}
@@ -0,0 +1,377 @@
"use client";
import { TriangleAlert } from "lucide-react";
import { useState } from "react";
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Karte, Textbereich } from "@/components/ui";
import { MonoBlock } from "@/components/ui/MonoBlock";
import { optionenPruefen } from "@/lib/ssh/optionen";
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
import { PLATZHALTER, platzhalterErsetzen, type PlatzhalterWerte } from "@/lib/vorlagen";
import { vorlageSpeichernAktion } from "@/server/actions/vorlagen";
type SudoModus = "KEINER" | "BEFEHLE" | "VOLL";
export type VorlageFormularVorgabe = {
id?: string;
name: string;
beschreibung: string;
schluesselTyp: "ED25519" | "RSA4096";
kategorie: "DIENST" | "PERSON";
standardLaufzeitTage: number | null;
zielbenutzer: string;
zielbenutzerAnlegen: boolean;
sudoModus: SudoModus;
sudoBefehle: string[];
authorizedKeysOptionen: string;
sshConfigAlias: string;
knownHostsEintragen: boolean;
privatNachAusrollenLoeschen: boolean;
};
export const LEERE_VORLAGE: VorlageFormularVorgabe = {
name: "",
beschreibung: "",
schluesselTyp: "ED25519",
kategorie: "DIENST",
standardLaufzeitTage: null,
zielbenutzer: "",
zielbenutzerAnlegen: false,
sudoModus: "KEINER",
sudoBefehle: [],
authorizedKeysOptionen: "",
sshConfigAlias: "",
knownHostsEintragen: true,
privatNachAusrollenLoeschen: false,
};
/** Beispielwerte der Vorschau – sichtbar andere als die Probewerte des Servers. */
const BEISPIEL: Required<PlatzhalterWerte> = {
quellHost: "srv-build01",
quellHostIp: "10.0.0.5",
zielHost: "srv-web01",
zielHostIp: "10.0.0.20",
};
type Vorschau = { wert: string | null; fehler?: string };
/**
* Die Vorschau ist nur Anzeige (#019): Fehler werden zum Hinweis, nicht zum Absturz – die
* verbindliche Pruefung macht der Server beim Speichern mit denselben Funktionen.
*/
function vorschau(berechnen: () => string | null): Vorschau {
try {
return { wert: berechnen() };
} catch (fehler) {
return { wert: null, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
}
}
function befehlsZeilen(text: string): string[] {
return text
.split("\n")
.map((b) => b.trim())
.filter(Boolean);
}
function Vorschauzeile({ titel, ergebnis, leer }: { titel: string; ergebnis: Vorschau; leer: string }) {
return (
<div className="flex min-w-0 flex-col gap-1">
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
<dd className="min-w-0">
{ergebnis.fehler ? (
<span className="text-danger text-[13.5px]">{ergebnis.fehler}</span>
) : ergebnis.wert ? (
<code className="text-text font-mono text-[13px] break-all">{ergebnis.wert}</code>
) : (
<span className="text-muted text-[13.5px]">{leer}</span>
)}
</dd>
</div>
);
}
/** Formular fuer neue und bestehende Vorlagen (#019). Regeln prueft der Server. */
export function VorlagenFormular({ vorgabe }: { vorgabe: VorlageFormularVorgabe }) {
const bearbeiten = Boolean(vorgabe.id);
const { zustand, absenden, laeuft } = useFormularAktion(vorlageSpeichernAktion);
const f = zustand.feldfehler ?? {};
// Kontrolliert nur, was die Vorschau braucht; der Rest bleibt unkontrolliert.
const [zielbenutzer, setZielbenutzer] = useState(vorgabe.zielbenutzer);
const [sudoModus, setSudoModus] = useState<SudoModus>(vorgabe.sudoModus);
const [befehle, setBefehle] = useState(vorgabe.sudoBefehle.join("\n"));
const [optionen, setOptionen] = useState(vorgabe.authorizedKeysOptionen);
const [alias, setAlias] = useState(vorgabe.sshConfigAlias);
const optionenVorschau = vorschau(() =>
optionen.trim() ? optionenPruefen(platzhalterErsetzen(optionen.trim(), BEISPIEL)) || null : null,
);
const aliasVorschau = vorschau(() => (alias.trim() ? platzhalterErsetzen(alias.trim(), BEISPIEL) : null));
const sudoersVorschau = vorschau(() =>
sudoModus === "KEINER" ? null : sudoersInhalt(zielbenutzer.trim(), sudoModus, befehlsZeilen(befehle)),
);
return (
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
<Formularfehler text={zustand.fehler} />
<Karte titel="Allgemein">
<div className="flex flex-col gap-4">
<Feld
beschriftung="Name"
name="name"
defaultValue={vorgabe.name}
required
maxLength={80}
autoComplete="off"
placeholder="z. B. Monitoring-Zugang"
fehler={f["name"]}
/>
<Textbereich
beschriftung="Beschreibung (optional)"
name="beschreibung"
defaultValue={vorgabe.beschreibung}
maxLength={500}
fehler={f["beschreibung"]}
/>
</div>
</Karte>
<Karte titel="Schlüssel">
<div className="flex flex-col gap-5">
<Feldgruppe legende="Typ" fehler={f["schluesselTyp"]}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Ankreuzfeld
type="radio"
name="schluesselTyp"
value="ED25519"
defaultChecked={vorgabe.schluesselTyp === "ED25519"}
beschriftung={<span className="font-mono">ed25519</span>}
erklaerung="Empfohlen – kurz, schnell, sicher."
/>
<Ankreuzfeld
type="radio"
name="schluesselTyp"
value="RSA4096"
defaultChecked={vorgabe.schluesselTyp === "RSA4096"}
beschriftung={<span className="font-mono">rsa-4096</span>}
erklaerung="Nur für alte Gegenstellen ohne ed25519."
/>
</div>
</Feldgruppe>
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Ankreuzfeld
type="radio"
name="kategorie"
value="DIENST"
defaultChecked={vorgabe.kategorie === "DIENST"}
beschriftung="Dienst"
erklaerung="Host-zu-Host oder Automatisierung; sshkv verwahrt den Private Key."
/>
<Ankreuzfeld
type="radio"
name="kategorie"
value="PERSON"
defaultChecked={vorgabe.kategorie === "PERSON"}
beschriftung="Person"
erklaerung="Der Private Key bleibt bei der Person; kein Benutzer anlegen, kein sudo."
/>
</div>
</Feldgruppe>
<Feld
beschriftung="Standard-Laufzeit in Tagen (optional)"
name="standardLaufzeitTage"
type="number"
inputMode="numeric"
min={1}
max={3650}
defaultValue={vorgabe.standardLaufzeitTage ?? ""}
hinweis="Leer lassen für Schlüssel ohne Ablaufdatum."
fehler={f["standardLaufzeitTage"]}
className="sm:max-w-xs"
/>
</div>
</Karte>
<Karte titel="Zielkonto" untertitel="Unter welchem Benutzer der Schlüssel auf dem Zielhost landet">
<div className="flex flex-col gap-5">
<Feld
beschriftung="Zielbenutzer"
name="zielbenutzer"
value={zielbenutzer}
onChange={(e) => setZielbenutzer(e.target.value)}
mono
required
maxLength={32}
autoComplete="off"
spellCheck={false}
placeholder="deploy"
hinweis="Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen."
fehler={f["zielbenutzer"]}
className="sm:max-w-xs"
/>
<Ankreuzfeld
name="zielbenutzerAnlegen"
value="ja"
defaultChecked={vorgabe.zielbenutzerAnlegen}
beschriftung="Benutzer anlegen, falls er fehlt"
erklaerung="sshkv legt ihn beim Ausrollen an und entfernt ihn beim Widerruf wieder."
/>
<Feldgruppe
legende="sudo-Rechte"
hinweis="Nur für Benutzer, die sshkv selbst anlegt."
fehler={f["sudoModus"]}
>
<div className="grid gap-x-4 sm:grid-cols-3">
{(
[
["KEINER", "keine"],
["BEFEHLE", "bestimmte Befehle"],
["VOLL", "voll"],
] as const
).map(([wert, text]) => (
<Ankreuzfeld
key={wert}
type="radio"
name="sudoModus"
value={wert}
checked={sudoModus === wert}
onChange={() => setSudoModus(wert)}
beschriftung={text}
/>
))}
</div>
</Feldgruppe>
{/* Ausgeblendet statt entfernt, damit die Befehle beim Hin- und Herschalten bleiben. */}
<Textbereich
beschriftung="Erlaubte Befehle"
name="sudoBefehle"
value={befehle}
onChange={(e) => setBefehle(e.target.value)}
rows={4}
spellCheck={false}
placeholder={"/usr/bin/systemctl restart nginx\n/usr/bin/systemctl reload nginx"}
hinweis="Ein Befehl je Zeile, mit absolutem Pfad. Argumente sind erlaubt, Platzhalter nicht."
fehler={f["sudoBefehle"]}
className={sudoModus === "BEFEHLE" ? "[&_textarea]:font-mono [&_textarea]:text-[13px]" : "hidden"}
/>
{sudoModus === "VOLL" && (
<p
role="note"
className="border-danger-border bg-danger-soft text-danger rounded-box flex items-start gap-2.5 border px-3 py-2.5 text-[14px]"
>
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
Voller sudo-Zugang ohne Passwort: Wer diesen Schlüssel hat, ist auf dem Zielhost faktisch{" "}
<strong className="font-semibold">root</strong>. Nur wählen, wenn bestimmte Befehle nicht
reichen.
</span>
</p>
)}
</div>
</Karte>
<Karte titel="authorized_keys & ssh">
<div className="flex flex-col gap-5">
<Feld
beschriftung="authorized_keys-Optionen (optional)"
name="authorizedKeysOptionen"
value={optionen}
onChange={(e) => setOptionen(e.target.value)}
mono
autoComplete="off"
spellCheck={false}
placeholder={'from="{{quellHostIp}}",no-port-forwarding'}
fehler={f["authorizedKeysOptionen"]}
/>
<Feld
beschriftung="Alias-Muster für ~/.ssh/config (optional)"
name="sshConfigAlias"
value={alias}
onChange={(e) => setAlias(e.target.value)}
mono
autoComplete="off"
spellCheck={false}
placeholder="deploy-{{zielHost}}"
fehler={f["sshConfigAlias"]}
/>
<div className="border-border bg-sidebar rounded-box text-muted border px-4 py-3 text-[13px]">
<p>
Platzhalter:{" "}
{PLATZHALTER.map((p, i) => (
<span key={p}>
{i > 0 && ", "}
<code className="text-text font-mono whitespace-nowrap">{`{{${p}}}`}</code>
</span>
))}
. Beim Ausrollen setzt sshkv Name und IP von Quell- und Zielhost ein.
</p>
<p className="mt-1.5">
Beispiel: <code className="text-text font-mono break-all">{'from="{{quellHostIp}}"'}</code> wird
zu <code className="text-text font-mono break-all">{`from="${BEISPIEL.quellHostIp}"`}</code>.
</p>
</div>
<div className="flex flex-col">
<Ankreuzfeld
name="knownHostsEintragen"
value="ja"
defaultChecked={vorgabe.knownHostsEintragen}
beschriftung="Host-Key des Ziels in known_hosts der Quelle eintragen"
/>
<Ankreuzfeld
name="privatNachAusrollenLoeschen"
value="ja"
defaultChecked={vorgabe.privatNachAusrollenLoeschen}
beschriftung="Private Key nach dem Ausrollen in sshkv löschen"
erklaerung="Dann liegt er nur noch auf dem Quellhost und lässt sich nicht erneut ausrollen."
/>
</div>
</div>
</Karte>
<Karte
titel="Vorschau"
untertitel={
<>
Mit Beispielwerten: Quelle <span className="font-mono">{BEISPIEL.quellHost}</span> (
<span className="font-mono">{BEISPIEL.quellHostIp}</span>), Ziel{" "}
<span className="font-mono">{BEISPIEL.zielHost}</span> (
<span className="font-mono">{BEISPIEL.zielHostIp}</span>)
</>
}
>
<div aria-live="polite" className="flex flex-col gap-4">
<dl className="grid gap-4 sm:grid-cols-2">
<Vorschauzeile titel="authorized_keys-Optionen" ergebnis={optionenVorschau} leer="keine" />
<Vorschauzeile titel="Alias" ergebnis={aliasVorschau} leer="kein Eintrag in ~/.ssh/config" />
</dl>
{sudoModus !== "KEINER" &&
(sudoersVorschau.fehler ? (
<p className="text-danger text-[13.5px]">sudoers: {sudoersVorschau.fehler}</p>
) : (
sudoersVorschau.wert && (
<div className="flex min-w-0 flex-col gap-1.5">
<p className="text-muted text-[13px] font-medium">
<span className="font-mono">/etc/sudoers.d/sshkv-{zielbenutzer.trim()}</span>
</p>
{/* Ohne Umbruch: sudoers ist zeilenbasiert, ein optischer Umbruch taeuschte eine
zweite Regel vor (#019). Der Block scrollt dann fuer sich. */}
<MonoBlock wert={sudoersVorschau.wert} beschriftung="sudoers-Inhalt" />
</div>
)
))}
</div>
</Karte>
<div className="flex flex-wrap gap-2.5">
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Vorlage anlegen"}
</Button>
<Button href="/vorlagen">Abbrechen</Button>
</div>
</form>
);
}
@@ -0,0 +1,24 @@
"use client";
/**
* „Alle auswählen“ fuer den Massenwiderruf (#024): setzt alle aktivierbaren Kaestchen, die zum
* Formular gehoeren. Die Auswahl selbst bleibt ein schlichtes GET-Formular – ohne JavaScript
* waehlt man eben einzeln.
*/
export function AlleAuswaehlen({ formular }: { formular: string }) {
return (
<label className="hover:bg-raised/60 rounded-box flex min-h-11 cursor-pointer items-center gap-3 px-2 text-[14px]">
<input
type="checkbox"
className="accent-accent size-[18px] cursor-pointer"
onChange={(e) => {
const kaestchen = document.querySelectorAll<HTMLInputElement>(
`input[type="checkbox"][form="${formular}"]:not(:disabled)`,
);
for (const k of kaestchen) k.checked = e.target.checked;
}}
/>
Alle auswählen
</label>
);
}
+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";
/** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */
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. */
@@ -217,6 +219,7 @@ export const vorgangEingabeSchema = z.object({
.nullable(),
typ: z.enum(["ED25519", "RSA4096"]).optional(),
mitPassphrase: z.boolean().optional(),
vorgaengerId: z.string().max(64).nullish(),
});
/** 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,
soll: readonly SollEintrag[],
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 {
const datei = authorizedKeysLesen(inhalt);
if (datei.block.art === "beschaedigt") {
@@ -227,11 +233,18 @@ export function blockSetzen(
let rohNeu: string[];
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 rohOhne = (von: number, bis?: number) =>
datei.zeilen
.slice(von, bis)
.filter(bleibt)
.map((z) => z.roh);
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 {
rohNeu = [...roh, ...neuerBlock];
rohNeu = [...rohOhne(0), ...neuerBlock];
// Ein neuer Block beginnt auf einer eigenen Zeile; die bisher letzte Zeile bekommt dafuer
// ihre fehlende Zeilenschaltung – die einzige Aenderung ausserhalb des Blocks.
endetMitZeilenende = true;
@@ -241,7 +254,9 @@ export function blockSetzen(
const hinweise: string[] = [];
const ausserhalb = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel" && !z.imBlock);
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) {
hinweise.push(
`${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 {};
}
+123
View File
@@ -0,0 +1,123 @@
"use server";
import { ZielkontoFehler } from "@/lib/ssh/zielkonto";
import { z } from "@/lib/validierung";
import { akteurVon } from "@/server/actions/formular";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { HostFehler } from "@/server/services/hosts";
import { erzeugenAnfordern } from "@/server/services/schluessel";
import {
VerwendungsFehler,
privateVerwendungAnlegen,
publicVerwendungAnlegen,
} from "@/server/services/verwendungen";
import { zielkontoAnfordern } from "@/server/services/zielkonten";
/**
* Schritte des Host-zu-Host-Assistenten (#020). Jeder Schritt ist eine eigene Aktion, die
* Oberflaeche verkettet sie und wartet dazwischen auf den Worker. So bleibt jede Aktion
* duenn, und jeder Schritt hat seine eigene Rechtepruefung.
*/
export type SchrittAntwort = {
readonly vorgangId?: string;
readonly privatVerwendungId?: string;
readonly fehler?: string;
/** Kritischer Schritt ohne frische Bestaetigung: dorthin, dann weiter. */
readonly bestaetigen?: boolean;
};
function alsAntwort(fehler: unknown): SchrittAntwort {
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
if (
fehler instanceof RechteFehler ||
fehler instanceof VerwendungsFehler ||
fehler instanceof ZielkontoFehler ||
fehler instanceof HostFehler
) {
return { fehler: fehler.message };
}
throw fehler;
}
const id = z.string().min(1).max(64);
export async function assistentErzeugenAktion(eingabe: {
name: string;
vorlageId: string | null;
ablaufAm: string | null;
}): Promise<SchrittAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
return {
vorgangId: await erzeugenAnfordern(
{
name: eingabe.name,
vorlageId: eingabe.vorlageId,
ablaufAm: eingabe.ablaufAm,
tags: ["host-zu-host"],
},
undefined,
akteurVon(benutzer),
),
};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function assistentZielkontoAktion(eingabe: {
hostId: string;
benutzer: string;
modus: "KEINER" | "BEFEHLE" | "VOLL";
befehle: string[];
}): Promise<SchrittAntwort> {
try {
// Ein neuer Benutzer mit sudo ist so viel wert wie root: Admin mit frischer Bestaetigung.
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
return {
vorgangId: await zielkontoAnfordern({ ...eingabe, hostId: id.parse(eingabe.hostId) }, benutzer),
};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function assistentZuweisenAktion(eingabe: {
schluesselId: string;
quelle: { hostId: string; benutzer: string; pfad: string };
ziel: { hostId: string; benutzer: string; optionen: string | null };
ssh: { alias: string; hostName: string; port: number; knownHosts: boolean };
}): Promise<SchrittAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
const akteur = akteurVon(benutzer);
const privatVerwendungId = await privateVerwendungAnlegen(
{
schluesselId: id.parse(eingabe.schluesselId),
hostId: id.parse(eingabe.quelle.hostId),
zielbenutzer: eingabe.quelle.benutzer,
pfad: eingabe.quelle.pfad,
sshConfig: {
alias: eingabe.ssh.alias,
hostName: eingabe.ssh.hostName,
benutzer: eingabe.ziel.benutzer,
port: eingabe.ssh.port,
},
knownHostsFuer: eingabe.ssh.knownHosts ? [id.parse(eingabe.ziel.hostId)] : [],
},
akteur,
);
await publicVerwendungAnlegen(
{
schluesselId: eingabe.schluesselId,
hostId: id.parse(eingabe.ziel.hostId),
zielbenutzer: eingabe.ziel.benutzer,
optionen: eingabe.ziel.optionen,
},
akteur,
);
return { privatVerwendungId };
} catch (fehler) {
return alsAntwort(fehler);
}
}
+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> {
try {
@@ -84,7 +88,9 @@ export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<Vorgan
vorgangId: await trockenlaufStarten(
"schluesselId" in auswahl
? { 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),
// Private-Key-Ziele nur fuer Admins – ein Operator rollt so nie einen Private Key aus.
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>>;
+44
View File
@@ -0,0 +1,44 @@
import { prisma } from "@/lib/db";
/** Was der Host-zu-Host-Assistent zur Auswahl braucht (#020) – nur oeffentliche Angaben. */
export async function assistentDaten() {
const [hosts, vorlagen] = await Promise.all([
prisma.host.findMany({
where: { archiviertAm: null },
select: {
id: true,
name: true,
adresse: true,
port: true,
bootstrapAm: true,
hostSchluessel: { where: { status: "VERTRAUT" }, select: { algorithmus: true, fingerprint: true } },
},
orderBy: { name: "asc" },
}),
prisma.vorlage.findMany({
where: { archiviertAm: null, kategorie: "DIENST" },
select: {
id: true,
name: true,
beschreibung: true,
standardLaufzeitTage: true,
zielbenutzer: true,
zielbenutzerAnlegen: true,
sudoModus: true,
sudoBefehle: true,
authorizedKeysOptionen: true,
sshConfigAlias: true,
knownHostsEintragen: true,
},
orderBy: [{ eingebaut: "desc" }, { name: "asc" }],
}),
]);
return {
hosts: hosts
.filter((h) => h.bootstrapAm !== null)
.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port, hostKeys: h.hostSchluessel })),
vorlagen,
};
}
export type AssistentDaten = Awaited<ReturnType<typeof assistentDaten>>;
+67 -1
View File
@@ -23,6 +23,7 @@ export const SCHLUESSEL_LISTE_SELECT = {
ablaufAm: true,
masterKeyVersion: true,
passphraseGeschuetzt: true,
widerrufEingeleitetAm: true,
person: { select: { id: true, name: true } },
_count: {
select: {
@@ -50,12 +51,15 @@ export const SCHLUESSEL_DETAIL_SELECT = {
tags: true,
ablaufAm: true,
zuletztVerwendetAm: true,
widerrufEingeleitetAm: true,
angelegtAm: true,
personId: true,
vorlageId: true,
person: { select: { id: true, name: true } },
vorlage: { select: { id: true, name: true } },
erstelltVon: { select: { name: true } },
vorgaenger: { select: { id: true, name: true, status: true } },
nachfolger: { select: { id: true, name: true, status: true } },
publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
@@ -101,13 +105,34 @@ function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
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 = {}) {
const q = filter.q?.trim().slice(0, 100);
const kategorie = alsKategorie(filter.kategorie);
const status = alsStatus(filter.status);
const where: Prisma.SchluesselWhereInput = {
...(kategorie ? { kategorie } : {}),
...(status ? { status } : {}),
AND: [statusFilter(status)],
...(q
? {
OR: [
@@ -166,3 +191,44 @@ export async function vorlagenAuswahl() {
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;
}
+40
View File
@@ -0,0 +1,40 @@
import { prisma } from "@/lib/db";
/**
* Lesemodelle fuer Vorlagen (#019). Vorlagen enthalten keine Geheimnisse – jede angemeldete
* Rolle darf sie sehen; die Seite blendet nur die Bearbeiten-Knoepfe aus.
*/
const AUSWAHL = {
id: true,
name: true,
beschreibung: true,
eingebaut: true,
schluesselTyp: true,
kategorie: true,
standardLaufzeitTage: true,
zielbenutzer: true,
zielbenutzerAnlegen: true,
sudoModus: true,
sudoBefehle: true,
authorizedKeysOptionen: true,
sshConfigAlias: true,
knownHostsEintragen: true,
privatNachAusrollenLoeschen: true,
archiviertAm: true,
} as const;
/** Eingebaute zuerst in Seed-Reihenfolge – sie sind der Normalfall, eigene die Ausnahme. */
export async function vorlagenListe() {
return prisma.vorlage.findMany({
where: { archiviertAm: null },
orderBy: [{ eingebaut: "desc" }, { angelegtAm: "asc" }, { name: "asc" }],
select: AUSWAHL,
});
}
export type VorlagenListenEintrag = Awaited<ReturnType<typeof vorlagenListe>>[number];
export async function vorlageDetail(id: string) {
return prisma.vorlage.findUnique({ where: { id }, select: AUSWAHL });
}
+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[];
/** Fingerprints, die nie aus dem Block fallen duerfen (Deployment-Key). */
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. */
readonly verwendungIds: { readonly drin: string[]; readonly raus: string[] };
};
@@ -58,6 +60,7 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
select: {
id: true,
optionen: true,
ausserhalbEntfernen: true,
zustand: true,
angelegtAm: 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 raus: string[] = [];
const ausserhalbEntfernen: string[] = [];
for (const verwendung of verwendungen) {
const aktiv = verwendung.schluessel.status === "AKTIV" || verwendung.schluessel.status === "ROTIERT";
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,
});
drin.push(verwendung.id);
if (verwendung.ausserhalbEntfernen) ausserhalbEntfernen.push(verwendung.schluessel.fingerprint);
} else {
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). */
+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 };
/** 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 = {
readonly schluesselId: string;
readonly hostId?: string | null;
@@ -46,11 +49,12 @@ export async function publicVerwendungAnlegen(eingabe: PublicZuweisung, akteur:
}
const schluessel = await prisma.schluessel.findUnique({
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.status !== "AKTIV")
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich zuweisen.");
if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
if (schluessel.kategorie === "DEPLOYMENT") {
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. */
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,
/** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */
mitPrivat = false,
): 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 ?? [
...(await zieleVonVerwendungen({
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
@@ -197,11 +235,13 @@ export async function privateVerwendungAnlegen(eingabe: PrivateZuweisung, akteur
kategorie: true,
privatGeloeschtAm: true,
masterKeyVersion: true,
widerrufEingeleitetAm: true,
},
});
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
if (schluessel.status !== "AKTIV")
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich ausrollen.");
if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
if (schluessel.kategorie !== "DIENST")
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).
+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;
}
+31
View File
@@ -0,0 +1,31 @@
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
import { istGeschuetzt, sudoersInhalt, ZielkontoFehler, type SudoModus } from "@/lib/ssh/zielkonto";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Zielkonten anfordern (#018). Nur Admins mit frischer Bestaetigung (Recht
* zielkontenVerwalten) – ein neuer Benutzer mit sudo ist so viel wert wie root.
*/
export function zielkontoEingabePruefen(
benutzer: string,
modus: SudoModus,
befehle: readonly string[],
): void {
if (!BENUTZERNAME_MUSTER.test(benutzer)) throw new ZielkontoFehler("Ungültiger Benutzername.");
if (istGeschuetzt(benutzer))
throw new ZielkontoFehler(`„${benutzer}“ verwaltet sshkv nicht als Zielkonto.`);
sudoersInhalt(benutzer, modus, befehle);
}
export async function zielkontoAnfordern(
eingabe: { hostId: string; benutzer: string; modus: SudoModus; befehle: readonly string[] },
akteur: { id: string },
): Promise<string> {
zielkontoEingabePruefen(eingabe.benutzer, eingabe.modus, eingabe.befehle);
return vorgangEinreihen({
art: "ZIELKONTO_ANLEGEN",
angefordertVonId: akteur.id,
hostId: eingabe.hostId,
eingabe: { benutzer: eingabe.benutzer, modus: eingabe.modus, befehle: [...eingabe.befehle] },
});
}
+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));
const stand = await authorizedKeysHolen(verbindung, host, 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 {
art: "public",
hostId: host.id,
@@ -249,7 +249,7 @@ export const ausrollenHandler: Handler = async ({ vorgang }) => {
);
}
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) {
throw new VorgangsFehler(
`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 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[] = [];
for (const verwendung of verwendungen) {
const pfad = relativenPfadPruefen(verwendung.pfad);
@@ -100,7 +103,7 @@ async function gewollteDateien(
port?: number;
} | null;
if (sshConfig?.alias && sshConfig.hostName && sshConfig.benutzer) {
config.push({
config.set(sshConfig.alias, {
alias: sshConfig.alias,
hostName: sshConfig.hostName,
benutzer: sshConfig.benutzer,
@@ -127,7 +130,7 @@ async function gewollteDateien(
dateien.push({
art: "config",
pfad: ".ssh/config",
inhalt: Buffer.from(sshConfigZeilen(config).join("\n")),
inhalt: Buffer.from(sshConfigZeilen([...config.values()]).join("\n")),
modus: "0600",
});
dateien.push({
+4
View File
@@ -103,12 +103,15 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
vorlageId: eingabe.vorlageId,
erstelltVonId: akteur === "System" ? null : akteur.id,
passphraseGeschuetzt: Boolean(passphrase),
vorgaengerId: eingabe.vorgaengerId ?? null,
...neu.versiegelt,
},
select: { id: true },
});
} catch (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(
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
);
@@ -127,6 +130,7 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
typ,
kategorie: "DIENST",
passphraseGeschuetzt: Boolean(passphrase),
...(eingabe.vorgaengerId ? { vorgaengerId: eingabe.vorgaengerId } : {}),
},
});
return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } };
+98
View File
@@ -0,0 +1,98 @@
import { prisma } from "@/lib/db";
import { sudoersDatei, sudoersInhalt, type SudoModus } from "@/lib/ssh/zielkonto";
import { protokollieren } from "@/server/services/protokoll";
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
import { hostLaden } from "@/worker/ablaeufe/hostkey";
import { hostVerbinden } from "@/worker/ssh/host";
import { zielkontoAnwenden, zielkontoEntfernen } from "@/worker/ssh/zielkonto";
/** Vorgaenge ZIELKONTO_ANLEGEN und ZIELKONTO_ENTFERNEN (#018). */
type Eingabe = { benutzer?: unknown; modus?: unknown; befehle?: unknown };
function eingabeLesen(roh: unknown): { benutzer: string; modus: SudoModus; befehle: string[] } {
const e = (roh ?? {}) as Eingabe;
if (typeof e.benutzer !== "string") throw new VorgangsFehler("Kein Benutzer angegeben.");
const modus = (["KEINER", "BEFEHLE", "VOLL"] as const).find((m) => m === e.modus) ?? "KEINER";
const befehle = Array.isArray(e.befehle) ? e.befehle.filter((b): b is string => typeof b === "string") : [];
return { benutzer: e.benutzer, modus, befehle };
}
async function akteurVon(id: string | null) {
const benutzer = id
? await prisma.benutzer.findUnique({ where: { id }, select: { id: true, name: true } })
: null;
return benutzer ?? ("System" as const);
}
export const zielkontoAnlegenHandler: Handler = async ({ vorgang }) => {
const host = await hostLaden(vorgang.hostId);
const konto = eingabeLesen(vorgang.eingabe);
const verbindung = await hostVerbinden(host);
let angelegt: boolean;
try {
({ angelegt } = await zielkontoAnwenden(verbindung, host, konto));
} finally {
verbindung.end();
}
const inhalt = sudoersInhalt(konto.benutzer, konto.modus, konto.befehle);
const vorher = await prisma.zielkonto.findUnique({
where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } },
});
await prisma.zielkonto.upsert({
where: { hostId_benutzername: { hostId: host.id, benutzername: konto.benutzer } },
create: {
hostId: host.id,
benutzername: konto.benutzer,
vonSshkvAngelegt: angelegt,
sudoModus: konto.modus,
sudoBefehle: konto.befehle,
sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null,
sudoersInhalt: inhalt,
},
update: {
// Wer den Benutzer einmal angelegt hat, bleibt dabei – sonst loeschte ein Widerruf ihn nie.
vonSshkvAngelegt: (vorher?.vonSshkvAngelegt && !vorher.entferntAm) || angelegt,
sudoModus: konto.modus,
sudoBefehle: konto.befehle,
sudoersDatei: inhalt ? sudoersDatei(konto.benutzer) : null,
sudoersInhalt: inhalt,
entferntAm: null,
},
});
await protokollieren({
akteur: await akteurVon(vorgang.angefordertVonId),
aktion: angelegt ? "zielkonto.angelegt" : "zielkonto.geaendert",
ergebnis: "ERFOLG",
hostName: host.name,
zielbenutzer: konto.benutzer,
diff: inhalt ?? "(keine sudoers-Datei)",
details: { sudoModus: konto.modus },
});
return { ergebnis: { angelegt, benutzer: konto.benutzer } };
};
export const zielkontoEntfernenHandler: Handler = async ({ vorgang }) => {
const host = await hostLaden(vorgang.hostId);
const { benutzer } = eingabeLesen(vorgang.eingabe);
const konto = await prisma.zielkonto.findUnique({
where: { hostId_benutzername: { hostId: host.id, benutzername: benutzer } },
});
if (!konto || konto.entferntAm) throw new VorgangsFehler("Dieses Zielkonto verwaltet sshkv nicht (mehr).");
const verbindung = await hostVerbinden(host);
try {
// Den Benutzer selbst nur loeschen, wenn sshkv ihn angelegt hat.
await zielkontoEntfernen(verbindung, host, benutzer, konto.vonSshkvAngelegt);
} finally {
verbindung.end();
}
await prisma.zielkonto.update({ where: { id: konto.id }, data: { entferntAm: new Date() } });
await protokollieren({
akteur: await akteurVon(vorgang.angefordertVonId),
aktion: "zielkonto.entfernt",
ergebnis: "ERFOLG",
hostName: host.name,
zielbenutzer: benutzer,
details: { benutzerGeloescht: konto.vonSshkvAngelegt },
});
return { ergebnis: { benutzerGeloescht: konto.vonSshkvAngelegt } };
};
+6 -1
View File
@@ -1,13 +1,15 @@
import type { HandlerTabelle } from "@/worker/ausfuehren";
import { abgleichHandler } from "@/worker/ablaeufe/abgleich";
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
import { zielkontoAnlegenHandler, zielkontoEntfernenHandler } from "@/worker/ablaeufe/zielkonto";
/**
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
* Welche Vorgangsart wer ausfuehrt.
*/
export const HANDLER: HandlerTabelle = {
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
@@ -20,4 +22,7 @@ export const HANDLER: HandlerTabelle = {
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
VERBINDUNGSTEST: verbindungstestHandler,
ABGLEICH: abgleichHandler,
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
};
+18 -2
View File
@@ -1,7 +1,13 @@
import "dotenv/config";
import type { Job } from "pg-boss";
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 { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
@@ -45,7 +51,17 @@ async function starten(): Promise<void> {
await aufraeumen();
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) => {
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();
});
+116
View File
@@ -0,0 +1,116 @@
import { execFileSync } from "node:child_process";
import { expect, test, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Host-zu-Host-Assistent mit der Vorlage „Deployment-Zugang“ (#018–#020), gebauter Stand +
* Worker: Benutzer deploy auf B anlegen, Private Key auf A, Public Key auf B, Dry-Run,
* ausrollen, Verbindung testen.
*/
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
const PORT = {
a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221",
b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222",
};
test.setTimeout(240_000);
function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string {
return execFileSync(
"docker",
["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl],
{
encoding: "utf8",
},
);
}
async function hostEinrichten(page: Page, name: string, port: string) {
await page.goto("/hosts/neu");
await page.getByLabel("Name").fill(name);
await page.getByLabel("Adresse").fill("127.0.0.1");
await page.getByLabel("Port").fill(port);
await page.getByRole("button", { name: "Host anlegen" }).click();
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
await page.getByRole("button", { name: "Zugang einrichten" }).click();
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
}
test.beforeAll(() => {
const reset =
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
aufZiel("ziel-a", reset);
aufZiel("ziel-b", reset);
});
test("Deployment-Zugang mit dem Assistenten", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
await page.goto("/anmelden");
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
// Frische Bestaetigung vorab (Benutzer anlegen verlangt sie); der Mock braucht auth_time.
await page.goto("/bestaetigen?ziel=%2Fassistent");
await page.getByRole("link", { name: /erneut anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Host-zu-Host-Assistent" })).toBeVisible();
// 1. Vorlage & Zweck
await page.getByLabel("Vorlage").selectOption({ label: "Deployment-Zugang" });
await page.getByLabel("Zweck").fill(`zz-e2e Claude Code Deployment ${kennung}`);
await expect(page.getByLabel("Ablaufdatum (optional)")).not.toHaveValue("");
await page.getByRole("button", { name: "Weiter" }).click();
// 2. Quelle
await page.getByLabel("Host A").selectOption({ label: hostA });
await page.getByRole("button", { name: "Weiter" }).click();
// 3. Ziel
await page.getByLabel("Host B").selectOption({ label: hostB });
await expect(page.getByLabel("Benutzer auf B")).toHaveValue("deploy");
await expect(page.getByLabel("Einschränkungen in authorized_keys")).toHaveValue('from="127.0.0.1"');
await expect(page.getByLabel("Alias auf A")).toHaveValue(`deploy-${hostB}`);
// Im Docker-Netz kommt A nicht von 127.0.0.1 und erreicht B unter seinem Dienstnamen.
await page.getByLabel("Einschränkungen in authorized_keys").fill("no-agent-forwarding");
await page.getByLabel("HostName (aus Sicht von A)").fill("ziel-b");
await page.getByLabel("Port").fill("22");
await page.getByText("Fingerprint von B ist bestätigt").click();
await page.screenshot({ path: "docs/anleitung/bilder/assistent-ziel-desktop.png", fullPage: true });
await page.getByRole("button", { name: "Weiter" }).click();
// 4. Pruefen und vorbereiten
await expect(page.getByText(`„deploy“ auf ${hostB} anlegen`)).toBeVisible();
await page.getByRole("button", { name: "Vorbereiten" }).click();
await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 90_000 });
// 5. Dry-Run, ausrollen, testen
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await expect(page.getByText(`deploy@${hostB}:/home/deploy/.ssh/authorized_keys`)).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
await page.getByRole("button", { name: "Verbindung von A nach B testen" }).click();
await expect(page.getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: "docs/anleitung/bilder/assistent-fertig-desktop.png", fullPage: true });
expect(aufZiel("ziel-a", `ssh -o BatchMode=yes deploy-${hostB} id -un`).trim()).toBe("deploy");
for (const name of [hostA, hostB]) {
await page.goto("/hosts");
await page.getByRole("link", { name }).first().click();
await page.getByRole("button", { name: "Host archivieren" }).click();
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
await expect(page).toHaveURL(/\/hosts$/);
}
});
+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);
});
+82
View File
@@ -0,0 +1,82 @@
import { expect, test, type Page } from "@playwright/test";
/**
* Vorlagen im gebauten Stand (#019). Testvorlagen heissen zz-e2e-… und werden am Ende des
* Ablaufs archiviert, damit die Entwicklungsdatenbank sauber bleibt.
*/
async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader") {
await page.goto("/anmelden");
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator(`button[data-rolle="${rolle}"]`).click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
test("Admin legt eine Vorlage an, sieht Feldfehler und archiviert sie", async ({ page }) => {
const name = `zz-e2e-${Date.now().toString(36)}`;
await oidcAnmelden(page, "admin");
await page.goto("/vorlagen");
await expect(page.getByRole("heading", { name: "Deployment-Zugang" })).toBeVisible();
await page.getByRole("link", { name: "Vorlage anlegen" }).click();
await expect(page).toHaveURL(/\/vorlagen\/neu$/);
await page.getByLabel("Name").fill(name);
await page.getByLabel("Zielbenutzer").fill("zzdeploy");
await page.getByRole("checkbox", { name: /Benutzer anlegen/ }).check();
await page.getByLabel("authorized_keys-Optionen (optional)").fill('from="{{quellIp}}",no-pty');
// Die Vorschau meldet den Fehler schon vor dem Absenden, der Server dann am Feld.
await expect(page.getByText("Unbekannter Platzhalter {{quellIp}}.").first()).toBeVisible();
await page.getByRole("button", { name: "Vorlage anlegen" }).click();
await expect(page.locator("#feld-authorizedKeysOptionen-fehler")).toHaveText(
"Unbekannter Platzhalter {{quellIp}}.",
);
// Eingaben bleiben nach der Feldmeldung stehen.
await expect(page.getByLabel("Name")).toHaveValue(name);
await page.getByLabel("authorized_keys-Optionen (optional)").fill('from="{{quellHostIp}}",no-pty');
await page.getByLabel("Alias-Muster für ~/.ssh/config (optional)").fill("zz-{{zielHost}}");
await expect(page.getByText('from="10.0.0.5",no-pty')).toBeVisible();
await expect(page.getByText("zz-srv-web01")).toBeVisible();
// sudo „bestimmte Befehle“ blendet das Befehlsfeld ein, „voll“ warnt.
await page.getByRole("radio", { name: "voll" }).check();
await expect(page.getByRole("note")).toContainText("root");
await page.getByRole("radio", { name: "bestimmte Befehle" }).check();
await page.getByLabel("Erlaubte Befehle").fill("/usr/bin/systemctl restart nginx");
await expect(page.getByLabel("sudoers-Inhalt", { exact: true })).toContainText(
"zzdeploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx",
);
await page.getByRole("button", { name: "Vorlage anlegen" }).click();
await expect(page).toHaveURL(/\/vorlagen$/);
const karte = page.locator("article", { has: page.getByRole("heading", { name }) });
await expect(karte).toBeVisible();
await expect(karte.getByText('from="{{quellHostIp}}",no-pty')).toBeVisible();
await expect(karte.getByText("wird angelegt")).toBeVisible();
await karte.getByRole("link", { name: `${name} bearbeiten` }).click();
await page.getByRole("button", { name: "Vorlage archivieren" }).click();
await page.getByRole("dialog").getByRole("button", { name: "Archivieren" }).click();
await expect(page).toHaveURL(/\/vorlagen$/);
await expect(page.getByRole("heading", { name })).toHaveCount(0);
});
test("eingebaute Vorlagen haben keinen Archivieren-Knopf", async ({ page }) => {
await oidcAnmelden(page, "admin");
await page.goto("/vorlagen");
await page.getByRole("link", { name: "Deployment-Zugang bearbeiten" }).click();
await expect(page.getByLabel("Name")).toHaveValue("Deployment-Zugang");
await expect(page.getByRole("button", { name: "Vorlage archivieren" })).toHaveCount(0);
});
test("Leser sieht Vorlagen, darf aber keine anlegen oder bearbeiten", async ({ page }) => {
await oidcAnmelden(page, "reader");
await page.goto("/vorlagen");
await expect(page.getByRole("heading", { name: "Deployment-Zugang" })).toBeVisible();
await expect(page.getByRole("link", { name: "Vorlage anlegen" })).toHaveCount(0);
await expect(page.getByRole("link", { name: /bearbeiten/ })).toHaveCount(0);
await page.goto("/vorlagen/neu");
await expect(page).toHaveURL(/\/kein-zugriff/);
});

Some files were not shown because too many files have changed in this diff Show More