Compare commits
@@ -15,6 +15,26 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [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.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Schlüssel erzeugen (ed25519, RSA-4096, optional mit Passphrase), Paar oder Public Key importieren, Public Key exportieren; Liste mit Filter, Detailseite mit Verwendungen (#012)
|
||||||
|
- Personen verwalten; Self-Service „Meine Schlüssel“: jeder Benutzer hinterlegt eigene Public Keys, wirksam erst nach Zuweisung (#013)
|
||||||
|
- Private Key anzeigen und herunterladen – nur Admin, nur nach erneuter Bestätigung, genau einmal abholbar; Private Key aus SSHKeyVault löschen (#014)
|
||||||
|
- authorized_keys-Parser und verwalteter Block: fremde Zeilen bleiben bytegenau, beschädigte Blöcke werden nie überschrieben, Deployment-Key ist vor dem Entfernen geschützt (#015)
|
||||||
|
- Public Keys Hosts oder Hostgruppen zuweisen, Dry-Run mit Diff, Ausrollen mit Backup und optimistischer Sperre; Ansicht je Schlüssel und je Host (#016)
|
||||||
|
- Private Key auf einen Quell-Host ausrollen, mit verwaltetem Block in `~/.ssh/config` und `known_hosts` (#017)
|
||||||
|
- Verbindungstest von Host A nach Host B (#020, Teil)
|
||||||
|
- Zielbenutzer anlegen und sudoers pflegen – Grundlage, Oberfläche folgt (#018, Teil)
|
||||||
|
- Vorlagen: Platzhalter und Prüfung – Grundlage, Oberfläche folgt (#019, Teil)
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Importierte Private Keys reisen nur verschlüsselt im Job; Vorgang, Protokoll und Browser sehen sie nie (Tests)
|
||||||
|
- Private-Key-Ziele nimmt der Dry-Run nur für Admins auf
|
||||||
|
- Dateien in fremden Home-Verzeichnissen werden als deren Benutzer gelesen und geschrieben (Symlink-Schutz)
|
||||||
|
|
||||||
## [0.1.0] – 2026-10-12
|
## [0.1.0] – 2026-10-12
|
||||||
|
|
||||||
Phase 1: Grundgerüst, Anmeldung, Hosts und Bootstrap.
|
Phase 1: Grundgerüst, Anmeldung, Hosts und Bootstrap.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt (Wandzeit):** 1 h 25 min
|
**Gesamt (Wandzeit):** 2 h 25 min
|
||||||
**Summe der Issue-Zeiten:** 4 h 23 min – Sub-Agenten arbeiteten parallel an #002, #006, #008 und #011.
|
**Summe der Issue-Zeiten:** 7 h 08 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011 und #012–#014.
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -19,10 +19,20 @@
|
|||||||
| #009 Host-Key-Prüfung | 0 h 25 min |
|
| #009 Host-Key-Prüfung | 0 h 25 min |
|
||||||
| #010 Bootstrap | 0 h 35 min |
|
| #010 Bootstrap | 0 h 35 min |
|
||||||
| #011 Test-SSH-Container | 0 h 15 min |
|
| #011 Test-SSH-Container | 0 h 15 min |
|
||||||
|
| #012 Schlüssel erzeugen, importieren, exportieren | 0 h 25 min |
|
||||||
|
| #013 Personen und Self-Service | 0 h 10 min |
|
||||||
|
| #014 Private Key anzeigen | 0 h 10 min |
|
||||||
|
| #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 |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
| v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen**
|
| v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen**
|
||||||
|
| v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,3 +65,22 @@
|
|||||||
- **Eigene Testdatenbank je parallelem Lauf:** Sub-Agent und Hauptsitzung leerten sich gegenseitig die Tabellen (in `CLAUDE.md` festgehalten).
|
- **Eigene Testdatenbank je parallelem Lauf:** Sub-Agent und Hauptsitzung leerten sich gegenseitig die Tabellen (in `CLAUDE.md` festgehalten).
|
||||||
- **Prisma-Client in den Worker gebündelt:** Im Standalone-Abbild fand die ESM-Auflösung `@prisma/client` sonst nicht (erst im echten Docker-Stapel aufgefallen).
|
- **Prisma-Client in den Worker gebündelt:** Im Standalone-Abbild fand die ESM-Auflösung `@prisma/client` sonst nicht (erst im echten Docker-Stapel aufgefallen).
|
||||||
- **Worker-Benutzer in Compose wählbar** (`SSHKV_WORKER_USER`), weil Docker-Secrets ohne Swarm die Rechte der Host-Datei tragen.
|
- **Worker-Benutzer in Compose wählbar** (`SSHKV_WORKER_USER`), weil Docker-Secrets ohne Swarm die Rechte der Host-Datei tragen.
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 2 · 10:07–11:07
|
||||||
|
**Issue:** #012–#020
|
||||||
|
**Version:** 0.2.0
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- #012–#014 (Schlüssel, Personen/Self-Service, Private Key anzeigen) von einem Sub-Agenten in eigenem Worktree, danach gemergt; Konflikte in `useVorgang` und der Handler-Tabelle zusammengeführt
|
||||||
|
- #015 authorized_keys-Parser und Merge mit 26 Randfall-Tests
|
||||||
|
- #016 Public Keys ausrollen: Soll, Trockenlauf, Ausrollen, Hostseite, Schlüsselseite – Integrationstests und E2E
|
||||||
|
- #017 Private Key mit `~/.ssh/config` und `known_hosts` – Integrationstest „A meldet sich bei B an“, E2E für den ganzen Kern-Anwendungsfall
|
||||||
|
- Vorgezogen: #018 Zielkonto-Skripte (Integrationstests), #019 Vorlagen-Logik, #020 Verbindungstest
|
||||||
|
- Stand: 410 Unit-/DB-Tests, 30 Integrationstests, 12 E2E-Abläufe grün
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **Phase 3 teilweise vorgezogen,** weil Florian „weiter“ sagte, während der Agent noch an Phase 2 arbeitete – nur Teile ohne Schemaänderung, damit die Migrationen des Agenten nicht kollidieren.
|
||||||
|
- **Trockenlauf und Ausrollen als zwei Vorgänge mit Hash-Sperre:** Es wird nie etwas anderes geschrieben, als jemand gesehen hat.
|
||||||
|
- **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.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 152 KiB |
@@ -1,23 +1,60 @@
|
|||||||
# #012 Schlüssel erzeugen, importieren, exportieren
|
# #012 Schlüssel erzeugen, importieren, exportieren
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Schlüsselliste und -formular: erzeugen (ed25519, rsa-4096), importieren (Public oder Paar), exportieren; Kategorien, Tags, Ablaufdatum.
|
Schlüsselliste und -formular: erzeugen (ed25519, rsa-4096), importieren (Public oder Paar), exportieren; Kategorien, Tags, Ablaufdatum.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Liste mit Filter
|
- [x] Liste mit Filter
|
||||||
- [ ] Erzeugen-Dialog
|
- [x] Erzeugen-Dialog
|
||||||
- [ ] Import mit Prüfung
|
- [x] Import mit Prüfung
|
||||||
- [ ] Export Public Key
|
- [x] Export Public Key
|
||||||
- [ ] Schlüsseldetail-Gerüst mit Verwendungen
|
- [x] Schlüsseldetail-Gerüst mit Verwendungen
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Private Keys erscheinen nie in Listen-Antworten
|
- [x] Private Keys erscheinen nie in Listen-Antworten
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#006
|
#006
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Migration `20261012100000_schluessel_vorgaenge`:** Vorgangsarten `SCHLUESSEL_ERZEUGEN`,
|
||||||
|
`SCHLUESSEL_IMPORTIEREN`, `PRIVATE_KEY_LOESCHEN` (für #014 gleich mit), CHECK
|
||||||
|
`schluessel_slug_gueltig` (`^[a-z0-9-]{1,64}$`, der Slug landet in Dateinamen) und
|
||||||
|
`schluessel_geloescht_ohne_private_key`.
|
||||||
|
- **Erzeugen und Paar-Import laufen im Worker** (`src/worker/ablaeufe/schluessel.ts`). Das Web
|
||||||
|
prüft nur Metadaten (Slug frei, Person/Vorlage vorhanden, Ablauf nicht in der Vergangenheit)
|
||||||
|
und reiht den Vorgang ein. Passphrase und importierter Private Key reisen wie das
|
||||||
|
Bootstrap-Passwort mit `SSHKV_APP_SECRET` verschlüsselt im Job (`geheim`), nie in
|
||||||
|
`Vorgang.eingabe`; der Worker löscht den Job danach. Der Worker prüft die Metadaten erneut.
|
||||||
|
- **Kategorie:** Erzeugen und Paar-Import legen immer `DIENST` an. Personen-Keys speichert sshkv
|
||||||
|
nie mit Private Key (DB-CHECK), Deployment-Keys verwaltet der Worker selbst. Der
|
||||||
|
Public-Key-Import kennt `PERSON` und `DIENST`.
|
||||||
|
- **Passphrase:** beim Erzeugen optional; gespeichert wird dann der passphrasengeschützte
|
||||||
|
OpenSSH-Key (`passphraseGeschuetzt = true`). Beim Import dient sie nur zum Lesen von Public Key
|
||||||
|
und Fingerprint und wird verworfen; gespeichert wird der Key so, wie er kam. Hinweis in der
|
||||||
|
Oberfläche: „Für automatische Anmeldungen (Host-zu-Host) ungeeignet.“
|
||||||
|
- **Typ:** `RSA4096` nur bei genau 4096 Bit, sonst `ANDERER` (z. B. RSA-3072, ecdsa). Die RSA-
|
||||||
|
Mindestlänge 2048 gilt beim Import.
|
||||||
|
- **Slug:** aus dem Zweck abgeleitet (Umlaute gefaltet), bei Kollision `-2`, `-3` …; ein
|
||||||
|
ausdrücklich gewünschter Kurzname muss frei sein. Slug, Typ und Kommentar sind danach nicht
|
||||||
|
mehr änderbar – sie stehen schon in Dateinamen und authorized_keys-Zeilen.
|
||||||
|
- **Status von Hand** nur `AKTIV` ↔ `ABGELAUFEN`; `ROTIERT`/`WIDERRUFEN` setzen #023/#024,
|
||||||
|
Deployment-Keys sind ausgenommen (sonst erzeugte der Worker beim Start einen neuen).
|
||||||
|
- **Ablaufdatum** ohne Angabe aus `Vorlage.standardLaufzeitTage`.
|
||||||
|
- **„Private Key gespeichert?“** liest das Web aus `masterKeyVersion` (der CHECK
|
||||||
|
`schluessel_umschlag_vollstaendig` koppelt sie an das Chiffrat) – das Chiffrat wird im Web
|
||||||
|
nie selektiert.
|
||||||
|
- **Grenze abgesichert:** `tests/schluessel-grenze.test.ts` verlangt für jeden zeilenliefernden
|
||||||
|
`prisma.schluessel.*`-Aufruf außerhalb von `src/worker` ein `select` und verbietet
|
||||||
|
`schluessel: true` als Relation; `tests/schluessel.test.ts` prüft, dass weder Vorgang,
|
||||||
|
Protokoll noch pg-boss-Job nach Erzeugen/Import den Key oder die Passphrase enthalten.
|
||||||
|
- **Platzhalter** für #016/#017: `src/app/(app)/schluessel/[id]/PublicAusrollenAktion.tsx` und
|
||||||
|
`PrivateAusrollenAktion.tsx`, Props `{ schluesselId: string; darf: boolean }`, rendern `null`.
|
||||||
|
- Personen-Keys zeigen im Detail nur die Karte „Public Key ausgerollt auf“ (eine Private-Karte
|
||||||
|
wäre immer leer).
|
||||||
|
- Prototyp `/schluessel/beispiel` samt `src/beispiel/schluessel.ts` entfernt; die Übersicht
|
||||||
|
(noch Prototyp, #026) verlinkt auf `/schluessel`.
|
||||||
|
|||||||
@@ -1,20 +1,41 @@
|
|||||||
# #013 Personen und Self-Service
|
# #013 Personen und Self-Service
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Personen verwalten; jeder angemeldete Benutzer kann eigene Public Keys hinterlegen, wirksam erst nach Zuweisung.
|
Personen verwalten; jeder angemeldete Benutzer kann eigene Public Keys hinterlegen, wirksam erst nach Zuweisung.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Personenliste/-detail
|
- [x] Personenliste/-detail
|
||||||
- [ ] Seite „Meine Schlüssel“
|
- [x] Seite „Meine Schlüssel“
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Leser kann nur eigene Keys hinterlegen, nicht zuweisen
|
- [x] Leser kann nur eigene Keys hinterlegen, nicht zuweisen
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#012
|
#012
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Personen pflegt, wer Schlüssel verwaltet** (Recht `schluesselVerwalten`, Admin und Operator) –
|
||||||
|
ein eigenes Recht wäre nur ein Alias. Lesen dürfen alle Rollen.
|
||||||
|
- **Archivieren statt Löschen:** die Schlüssel bleiben der Person zugeordnet (wer hatte bis wann
|
||||||
|
welchen Key?), die Kontoverknüpfung entfällt, damit der Self-Service nicht bei einer
|
||||||
|
archivierten Person landet. Die Notiz steht nicht im Protokoll, nur dass sie sich änderte.
|
||||||
|
- **Self-Service `/meine-schluessel`** (Recht `eigeneSchluesselHinterlegen`, alle Rollen,
|
||||||
|
Navigation „Meine Schlüssel“): beim ersten Aufruf wird die eigene Person angelegt oder
|
||||||
|
verknüpft – per `benutzerId`, sonst per E-Mail, aber **nur bei vom Anbieter bestätigter
|
||||||
|
Adresse** und genau einer freien, nicht archivierten Person. Dafür neue Spalte
|
||||||
|
`Benutzer.emailBestaetigt` (Migration `20261012103000_benutzer_email_bestaetigt`), gesetzt bei
|
||||||
|
jeder OIDC-Anmeldung. Ohne sie könnte jemand mit selbst gewählter Adresse die Keys einer fremden
|
||||||
|
Person sehen.
|
||||||
|
- Hinterlegte Keys: Kategorie `PERSON`, `selbstHinterlegt = true`, Protokoll
|
||||||
|
`schluessel.selbst_hinterlegt`. Hinweis in der Oberfläche: „Wirksam erst, wenn ein Operator
|
||||||
|
oder Admin den Schlüssel einem Host zuweist.“
|
||||||
|
- **Entfernen** nur eigene, selbst hinterlegte Keys ohne jede Verwendung (auch keine entfernte –
|
||||||
|
der Verlauf am Key bleibt). Das Löschen ist bedingt (`deleteMany` mit „keine Verwendungen“),
|
||||||
|
damit eine gleichzeitige Zuweisung nicht per Cascade verschwindet. Fremde IDs gelten als „nicht
|
||||||
|
vorhanden“. Tests: `tests/personen.test.ts`.
|
||||||
|
- Die Services nehmen den angemeldeten Benutzer entgegen und leiten die Person selbst ab; die
|
||||||
|
Actions reichen keine `personId` von außen durch (struktureller Test).
|
||||||
|
|||||||
@@ -1,20 +1,52 @@
|
|||||||
# #014 Private Key anzeigen und herunterladen
|
# #014 Private Key anzeigen und herunterladen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** sicherheit
|
**Labels:** sicherheit
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Nur Admin, nur nach Re-Auth, immer protokolliert; Entschlüsselung im Worker.
|
Nur Admin, nur nach Re-Auth, immer protokolliert; Entschlüsselung im Worker.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Einmal-Abholung
|
- [x] Einmal-Abholung
|
||||||
- [ ] Option „Private Key nach Ausrollen löschen“
|
- [x] Option „Private Key nach Ausrollen löschen“
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Kein Caching, kein Logging
|
- [x] Kein Caching, kein Logging
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#012, #004
|
#012, #004
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Ablauf:** Web legt einen Vorgang `PRIVATE_KEY_ABHOLEN` an (Protokoll `privatekey.angefordert`)
|
||||||
|
→ der Worker öffnet den Umschlag und legt den Key mit `SSHKV_APP_SECRET` verschlüsselt in
|
||||||
|
`Vorgang.ergebnis` ab (`{ abholung: "v1.…" }`) → das Web holt ihn **genau einmal** ab: ein
|
||||||
|
einziges `UPDATE … FROM (SELECT … FOR UPDATE) RETURNING` liest das alte Ergebnis und setzt
|
||||||
|
`{ abgeholt: true }`; fünf gleichzeitige Abholungen ergeben genau eine (Test). Abholen darf
|
||||||
|
nur, wer angefordert hat. Protokoll `privatekey.angezeigt` / `privatekey.heruntergeladen` mit
|
||||||
|
Fingerprint, nie mit Key.
|
||||||
|
- **Die Status-Schnittstelle** `/api/vorgaenge/[id]` zeigt für eine bereitliegende Abholung nur
|
||||||
|
`{ bereit: true }`, nie das Chiffrat.
|
||||||
|
- **Verfall nach 2 Minuten** (`ABHOLFRIST_MS` in `src/lib/schluessel.ts`), dreifach abgesichert:
|
||||||
|
der Worker räumt alle 30 s und vor jeder neuen Abholung auf (`abholungenAufraeumen`, ein
|
||||||
|
einziges UPDATE → `{ verfallen: true }`), und das Web gibt eine ältere Abholung nicht mehr
|
||||||
|
heraus (überschreibt sie aber trotzdem). Ein Worker-Takt statt pg-boss-Zeitplan: dessen Raster
|
||||||
|
ist eine Minute.
|
||||||
|
- **Anzeige** im Dialog (Monospace, Kopier-Knopf, „Wird nicht erneut angezeigt“); der Key lebt
|
||||||
|
nur im Zustand des Dialogs und wird beim Schließen verworfen. **Download** als Datei `<slug>`
|
||||||
|
über `POST /api/private-key/[vorgangId]` (Formular, kein GET – ein Link oder Vorabruf würde die
|
||||||
|
einmalige Abholung verbrauchen), `Content-Disposition: attachment`,
|
||||||
|
`Cache-Control: no-store`. Der Route Handler prüft selbst Recht, Bestätigung und Herkunft:
|
||||||
|
wegen `Referrer-Policy: no-referrer` senden Browser bei Formular-POSTs `Origin: null`,
|
||||||
|
maßgeblich ist deshalb `Sec-Fetch-Site: same-origin` (oder ein passender Origin).
|
||||||
|
- **Re-Auth:** Fehlt die frische Bestätigung, leitet die Oberfläche nach
|
||||||
|
`/bestaetigen?ziel=/schluessel/<id>?privatekey=1` und öffnet danach den Dialog wieder. Der
|
||||||
|
Mock-OIDC liefert `auth_time` nur, wenn man es in die Claims schreibt – die E2E tut das.
|
||||||
|
- **Löschen** („Private Key aus SSHKeyVault löschen“): neues Recht `privateKeyLoeschen` (nur
|
||||||
|
Admin, mit Bestätigung), ausgeführt als Worker-Vorgang `PRIVATE_KEY_LOESCHEN`
|
||||||
|
(`privateKeyVerwerfen` in `src/worker/geheimnisse.ts`), damit nur diese Datei die
|
||||||
|
Chiffrat-Felder anfasst – auch zum Leeren. Setzt Chiffrat, DEK und Version auf `null` und
|
||||||
|
`privatGeloeschtAm`; Protokoll `privatekey.geloescht`. CHECK
|
||||||
|
`schluessel_geloescht_ohne_private_key` (Migration aus #012).
|
||||||
|
- **Deployment-Keys** gibt sshkv weder heraus noch löscht es deren Private Key: der Key ist root
|
||||||
|
auf allen Hosts, und ohne ihn käme der Worker nirgends mehr hin. Im Notfall wird rotiert.
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
# #015 authorized_keys: Parser und Merge
|
# #015 authorized_keys: Parser und Merge
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** backend, test
|
**Labels:** backend, test
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 15 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Reine Funktionen zum Lesen und Schreiben von `authorized_keys` mit verwaltetem Block.
|
Reine Funktionen zum Lesen und Schreiben von `authorized_keys` mit verwaltetem Block.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Optionen mit Anführungszeichen
|
- [x] Optionen mit Anführungszeichen
|
||||||
- [ ] CRLF, BOM, Kommentare, Duplikate
|
- [x] CRLF, BOM, Kommentare, Duplikate
|
||||||
- [ ] Block erzeugen/ersetzen, Rest bytegenau
|
- [x] Block erzeugen/ersetzen, Rest bytegenau
|
||||||
- [ ] Aussperrschutz Deployment-Key
|
- [x] Aussperrschutz Deployment-Key
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Umfangreiche Unit-Tests der Randfälle
|
- [x] Umfangreiche Unit-Tests der Randfälle
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#001
|
#001
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Bytegenau:** Zeilen werden an `\n` getrennt, ein `\r` bleibt Teil der Zeile; BOM und fehlende letzte Zeilenschaltung bleiben erhalten. Einzige Änderung außerhalb des Blocks: Fehlt der Block und der letzten Zeile die Zeilenschaltung, bekommt sie eine – sonst stünde der Block-Anfang in derselben Zeile.
|
||||||
|
- **CRLF-Dateien** (überwiegend `\r\n`) bekommen auch im Block CRLF.
|
||||||
|
- **Beschädigter Block** (Anfang ohne Ende, mehrere Blöcke, Ende vor Anfang): nie automatisch schreiben, `BlockFehler` mit Bitte um Handarbeit.
|
||||||
|
- **Erkennung am Fingerprint**, nicht am Kommentar; der Typ im Blob muss zum Zeilentyp passen.
|
||||||
|
- **Optionen:** Parser wie sshd (Kommas in Anführungszeichen, `\"`). Für Benutzereingaben strenger: nur bekannte Optionen, keine Steuerzeichen.
|
||||||
|
- **Aussperrschutz:** Ein `pflicht`-Fingerprint (Deployment-Key), der im Block stand, darf nicht verschwinden.
|
||||||
|
- Diff per LCS, für die Anzeige verdichtet (je 2 Zeilen Kontext).
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
# #016 Public Key ausrollen mit Dry-Run und Backup
|
# #016 Public Key ausrollen mit Dry-Run und Backup
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.2.0
|
**Meilenstein:** v0.2.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Zuweisungen Schlüssel × Host/Gruppe × Benutzer; Dry-Run-Diff, Backup, atomares Schreiben, Rechte.
|
Zuweisungen Schlüssel × Host/Gruppe × Benutzer; Dry-Run-Diff, Backup, atomares Schreiben, Rechte.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Remote-Skript
|
- [x] Remote-Skript
|
||||||
- [ ] Optimistische Sperre über Datei-Hash
|
- [x] Optimistische Sperre über Datei-Hash
|
||||||
- [ ] Diff-Box im Schlüsseldetail
|
- [x] Diff-Box im Schlüsseldetail
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Integrationstest Ausrollen/Entfernen
|
- [x] Integrationstest Ausrollen/Entfernen
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#015, #010
|
#015, #010
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Zwei Schritte:** TROCKENLAUF hält je Ziel Diff, Hinweise und die Hashes vorher/nachher fest; AUSROLLEN führt genau diesen Plan aus und bricht je Ziel ab, wenn sich Datei **oder** Soll geändert hat. Ein Trockenlauf gilt 15 Minuten und nur einmal.
|
||||||
|
- **Auf dem Host** alles als Zielbenutzer (`runuser`), Hash dort erneut geprüft, Sicherung (letzte 5 bleiben), Temp-Datei + `mv`, 0700/0600.
|
||||||
|
- **Nur gültiges UTF-8** wird bearbeitet, sonst ginge „bytegenau“ verloren.
|
||||||
|
- **Soll** = direkte und Gruppen-Zuweisungen; der Deployment-Key gehört immer zum Soll des Anmeldebenutzers (Aussperrschutz aus #015).
|
||||||
|
- Gruppen-Verwendungen: Zustand je Verwendung, Fehlschlag auf einem Host gewinnt. Zustand je Host folgt mit dem Abgleich (#021).
|
||||||
|
- Oberfläche: Zuweisen auf der Schlüsselseite, Dry-Run je Schlüssel und je Host.
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
# #017 Private Key ausrollen und ~/.ssh/config
|
# #017 Private Key ausrollen und ~/.ssh/config
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** backend, ui
|
**Labels:** backend, ui
|
||||||
**Geschätzt:** 4 h **Tatsächlich:** –
|
**Geschätzt:** 4 h **Tatsächlich:** 0 h 45 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Private Key auf Zielhost/-benutzer/-pfad, verwalteter Block in `~/.ssh/config`, `known_hosts`.
|
Private Key auf Zielhost/-benutzer/-pfad, verwalteter Block in `~/.ssh/config`, `known_hosts`.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Übertragung nur über stdin
|
- [x] Übertragung nur über stdin
|
||||||
- [ ] Warnung bei Passphrase
|
- [x] Warnung bei Passphrase
|
||||||
- [ ] nur Admin
|
- [x] nur Admin
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Integrationstest
|
- [x] Integrationstest
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#016
|
#016
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Dateien je Ziel:** Private Key (0600, keine Sicherungskopie), `.pub` (0644), verwalteter Block **am Anfang** von `~/.ssh/config` (ssh nimmt den ersten Treffer), verwalteter Block in `known_hosts` mit den vertrauten Host-Keys des Ziels unter dem HostName/Port aus der config.
|
||||||
|
- **Nie fremde Dateien überschreiben:** Liegt am Zielpfad schon etwas anderes, scheitert das Ziel im Trockenlauf.
|
||||||
|
- **Plan ohne Inhalt:** Der Trockenlauf zeigt beim Private Key nur die Aktion; weder Vorgang noch Protokoll noch Browser sehen den Schlüssel.
|
||||||
|
- **Nur Admins:** Zuweisen verlangt `privateKeyAusrollen`; der Trockenlauf nimmt Private-Key-Ziele nur für Admins auf.
|
||||||
|
- **Widerruf:** löschen oder umbenennen (`…sshkv-widerrufen.<Zeit>`), config- und known_hosts-Einträge verschwinden mit.
|
||||||
|
- Integrationstest: A meldet sich per Alias bei B an (StrictHostKeyChecking=yes). E2E im Browser für den ganzen Kern-Anwendungsfall.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sshkv",
|
"name": "sshkv",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "SSHKeyVault – zentrale Verwaltung von SSH-Schluesseln mit Weboberflaeche",
|
"description": "SSHKeyVault – zentrale Verwaltung von SSH-Schluesseln mit Weboberflaeche",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Schluessel erzeugen, importieren, Private Key loeschen (#012, #014).
|
||||||
|
--
|
||||||
|
-- Neue Vorgangsarten: Erzeugen und Import eines Paars laufen im Worker, weil nur er den
|
||||||
|
-- Master-Key hat. Das Loeschen eines gespeicherten Private Keys laeuft ebenfalls dort, damit
|
||||||
|
-- die Grenze „nur src/worker/geheimnisse.ts fasst das Chiffrat an“ einfach bleibt.
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_ERZEUGEN';
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'SCHLUESSEL_IMPORTIEREN';
|
||||||
|
ALTER TYPE "VorgangsArt" ADD VALUE 'PRIVATE_KEY_LOESCHEN';
|
||||||
|
|
||||||
|
-- Der Slug landet in Dateinamen (<slug>.pub, ~/.ssh/id_<slug>) und Kommentaren. Was hier
|
||||||
|
-- nicht passt, kommt nie bis in einen Pfad oder ein Shell-Skript.
|
||||||
|
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_slug_gueltig" CHECK (
|
||||||
|
"slug" ~ '^[a-z0-9-]{1,64}$'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- „Private Key geloescht“ und ein gespeichertes Chiffrat schliessen sich aus (#014).
|
||||||
|
ALTER TABLE "Schluessel" ADD CONSTRAINT "schluessel_geloescht_ohne_private_key" CHECK (
|
||||||
|
"privatGeloeschtAm" IS NULL OR "privatVerschluesselt" IS NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Self-Service „Meine Schluessel“ (#013): ein Konto wird per E-Mail nur dann mit einer
|
||||||
|
-- vorhandenen Person verknuepft, wenn der OIDC-Anbieter die Adresse bestaetigt hat. Sonst
|
||||||
|
-- koennte jemand mit selbst gewaehlter Adresse die Schluessel einer fremden Person sehen.
|
||||||
|
ALTER TABLE "Benutzer" ADD COLUMN "emailBestaetigt" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -104,6 +104,12 @@ enum VorgangsArt {
|
|||||||
ROTATION
|
ROTATION
|
||||||
WIDERRUF
|
WIDERRUF
|
||||||
PRIVATE_KEY_ABHOLEN
|
PRIVATE_KEY_ABHOLEN
|
||||||
|
/// Schluesselpaar im Worker erzeugen und versiegeln (#012).
|
||||||
|
SCHLUESSEL_ERZEUGEN
|
||||||
|
/// Importiertes Paar im Worker pruefen und versiegeln (#012).
|
||||||
|
SCHLUESSEL_IMPORTIEREN
|
||||||
|
/// Gespeicherten Private Key endgueltig verwerfen (#014).
|
||||||
|
PRIVATE_KEY_LOESCHEN
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VorgangsStatus {
|
enum VorgangsStatus {
|
||||||
@@ -129,6 +135,9 @@ model Benutzer {
|
|||||||
/// `sub` des OIDC-Anbieters; leer beim Notfall-Admin.
|
/// `sub` des OIDC-Anbieters; leer beim Notfall-Admin.
|
||||||
oidcSub String? @unique
|
oidcSub String? @unique
|
||||||
email String? @unique
|
email String? @unique
|
||||||
|
/// Hat der OIDC-Anbieter die Adresse bestaetigt (`email_verified`)? Nur dann verknuepft der
|
||||||
|
/// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013).
|
||||||
|
emailBestaetigt Boolean @default(false)
|
||||||
/// Anmeldename des Notfall-Admins.
|
/// Anmeldename des Notfall-Admins.
|
||||||
anmeldename String? @unique
|
anmeldename String? @unique
|
||||||
name String
|
name String
|
||||||
|
|||||||
@@ -124,10 +124,99 @@ async function demoDaten(): Promise<void> {
|
|||||||
console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`);
|
console.log(`Demo-Daten geprüft: ${DEMO_HOSTS.length} Hosts, ${DEMO_GRUPPEN.length} Gruppen.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demo-Personen und -Schluessel (#012, #013). Nur Public Keys: einen Private Key kann nur
|
||||||
|
* der Worker versiegeln. Die Paare werden hier frisch erzeugt und der Private Key sofort
|
||||||
|
* verworfen – es sind Attrappen, die nirgends Zugang geben.
|
||||||
|
*/
|
||||||
|
const DEMO_PERSONEN = [
|
||||||
|
{ name: "Florian Wilde", email: "florian@example.org", organisation: "IT" },
|
||||||
|
{ name: "Anna Berger", email: "a.berger@netzwerk-gmbh.example", organisation: "Netzwerk GmbH" },
|
||||||
|
{ name: "Mehmet Yılmaz", email: "mehmet@example.org", organisation: "IT" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TAG_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const DEMO_SCHLUESSEL: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
kategorie: "PERSON" | "DIENST";
|
||||||
|
person?: string;
|
||||||
|
tags: string[];
|
||||||
|
ablaufInTagen?: number;
|
||||||
|
kommentar: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
name: "Laptop Florian",
|
||||||
|
slug: "laptop-florian",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
person: "Florian Wilde",
|
||||||
|
tags: ["laptop"],
|
||||||
|
kommentar: "florian@laptop",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Wartungszugang Netzwerk GmbH",
|
||||||
|
slug: "wartung-netzwerk-gmbh",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
person: "Anna Berger",
|
||||||
|
tags: ["dienstleister"],
|
||||||
|
ablaufInTagen: 18,
|
||||||
|
kommentar: "anna@netzwerk-gmbh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Workstation Mehmet",
|
||||||
|
slug: "workstation-mehmet",
|
||||||
|
kategorie: "PERSON",
|
||||||
|
person: "Mehmet Yılmaz",
|
||||||
|
tags: [],
|
||||||
|
ablaufInTagen: 200,
|
||||||
|
kommentar: "mehmet@ws-07",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Monitoring zabbix → alle Hosts",
|
||||||
|
slug: "monitoring-zabbix",
|
||||||
|
kategorie: "DIENST",
|
||||||
|
tags: ["monitoring"],
|
||||||
|
kommentar: "zabbix@srv-mon01",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function demoSchluessel(): Promise<void> {
|
||||||
|
if (process.env["SSHKV_SEED_DEMO"] !== "1") return;
|
||||||
|
const { schluesselpaarErzeugen } = await import("../src/lib/ssh/openssh");
|
||||||
|
const personen = new Map<string, string>();
|
||||||
|
for (const p of DEMO_PERSONEN) {
|
||||||
|
const vorhanden = await prisma.person.findFirst({ where: { name: p.name }, select: { id: true } });
|
||||||
|
const person = vorhanden ?? (await prisma.person.create({ data: p, select: { id: true } }));
|
||||||
|
personen.set(p.name, person.id);
|
||||||
|
}
|
||||||
|
for (const s of DEMO_SCHLUESSEL) {
|
||||||
|
if (await prisma.schluessel.findUnique({ where: { slug: s.slug }, select: { id: true } })) continue;
|
||||||
|
const paar = await schluesselpaarErzeugen("ED25519", { kommentar: s.kommentar });
|
||||||
|
await prisma.schluessel.create({
|
||||||
|
data: {
|
||||||
|
name: s.name,
|
||||||
|
slug: s.slug,
|
||||||
|
typ: "ED25519",
|
||||||
|
kategorie: s.kategorie,
|
||||||
|
publicKey: paar.publicKeyZeile,
|
||||||
|
fingerprint: paar.fingerprint,
|
||||||
|
kommentar: s.kommentar,
|
||||||
|
tags: s.tags,
|
||||||
|
personId: s.person ? (personen.get(s.person) ?? null) : null,
|
||||||
|
ablaufAm: s.ablaufInTagen ? new Date(Date.now() + s.ablaufInTagen * TAG_MS) : null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Demo-Daten geprüft: ${DEMO_PERSONEN.length} Personen, ${DEMO_SCHLUESSEL.length} Schlüssel.`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await notfallAdmin();
|
await notfallAdmin();
|
||||||
await vorlagen();
|
await vorlagen();
|
||||||
await demoDaten();
|
await demoDaten();
|
||||||
|
await demoSchluessel();
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,16 @@ const ZIEL = process.env["SSHKV_BILDER"] ?? join(process.cwd(), "docs/anleitung/
|
|||||||
const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [
|
const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: string }[] = [
|
||||||
{ name: "anmelden", pfad: "/anmelden", oeffentlich: true },
|
{ name: "anmelden", pfad: "/anmelden", oeffentlich: true },
|
||||||
{ name: "uebersicht", pfad: "/" },
|
{ name: "uebersicht", pfad: "/" },
|
||||||
{ name: "schluessel-detail", pfad: "/schluessel/beispiel" },
|
{ name: "schluessel", pfad: "/schluessel" },
|
||||||
|
{ name: "schluessel-neu", pfad: "/schluessel/neu" },
|
||||||
|
// Der Demo-Key mit nahem Ablaufdatum zeigt die meisten Badges (SSHKV_SEED_DEMO=1, #012).
|
||||||
|
{
|
||||||
|
name: "schluessel-detail",
|
||||||
|
pfad: "/schluessel?q=wartung",
|
||||||
|
folgen: 'main table a[href^="/schluessel/"]',
|
||||||
|
},
|
||||||
|
{ name: "personen", pfad: "/personen" },
|
||||||
|
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
||||||
{ name: "hosts", pfad: "/hosts" },
|
{ name: "hosts", pfad: "/hosts" },
|
||||||
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
|||||||
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
||||||
import { BootstrapAktionen } from "./BootstrapAktionen";
|
import { BootstrapAktionen } from "./BootstrapAktionen";
|
||||||
import { sshdHinweise } from "@/lib/sshd-hinweise";
|
import { sshdHinweise } from "@/lib/sshd-hinweise";
|
||||||
|
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
||||||
|
import { verwendungenAufHost } from "@/server/queries/ziele";
|
||||||
import { HostKeyAktionen } from "./HostKeyAktionen";
|
import { HostKeyAktionen } from "./HostKeyAktionen";
|
||||||
import { HostKeyEntscheidung } from "./HostKeyEntscheidung";
|
import { HostKeyEntscheidung } from "./HostKeyEntscheidung";
|
||||||
import { bootstrapBefehlFuer } from "@/server/queries/bootstrap";
|
import { bootstrapBefehlFuer } from "@/server/queries/bootstrap";
|
||||||
@@ -95,6 +98,8 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
|||||||
const archiviert = host.archiviertAm !== null;
|
const archiviert = host.archiviertAm !== null;
|
||||||
const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert;
|
const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert;
|
||||||
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
|
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
|
||||||
|
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
|
||||||
|
const verwendungen = await verwendungenAufHost(host.id);
|
||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -217,7 +222,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
|||||||
|
|
||||||
<Karte
|
<Karte
|
||||||
titel="Schlüssel auf diesem Host"
|
titel="Schlüssel auf diesem Host"
|
||||||
untertitel={`${host.publicVerwendungen} Public-Key-Verwendungen · ${host.privateVerwendungen} Private-Key-Verwendungen`}
|
untertitel="Public Keys in authorized_keys und Private Keys, die sshkv hier verwaltet"
|
||||||
>
|
>
|
||||||
{host.zielkonten.length > 0 && (
|
{host.zielkonten.length > 0 && (
|
||||||
<p className="text-muted mb-4 text-[14px]">
|
<p className="text-muted mb-4 text-[14px]">
|
||||||
@@ -230,11 +235,64 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
|||||||
))}
|
))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<Leerzustand
|
{verwendungen.oeffentlich.length === 0 && verwendungen.privat.length === 0 ? (
|
||||||
icon={<KeyRound className="size-5" />}
|
<Leerzustand
|
||||||
titel="Folgt mit #016"
|
icon={<KeyRound className="size-5" />}
|
||||||
text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet."
|
titel="Noch keine Schlüssel zugewiesen"
|
||||||
/>
|
text="Zuweisen geht über die Seite eines Schlüssels („+ Ausrollen“)."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{verwendungen.oeffentlich.map((v) => (
|
||||||
|
<li
|
||||||
|
key={v.id}
|
||||||
|
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<a
|
||||||
|
href={`/schluessel/${v.schluessel.id}`}
|
||||||
|
className="text-text hover:text-accent font-medium"
|
||||||
|
>
|
||||||
|
{v.schluessel.name}
|
||||||
|
</a>
|
||||||
|
<span className="text-muted block font-mono text-[12.5px] break-all">
|
||||||
|
Public · {v.zielbenutzer}@{host.name}
|
||||||
|
{v.hostgruppe ? ` · über ${v.hostgruppe.name}` : ""}
|
||||||
|
{v.optionen ? ` · ${v.optionen}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<VerwendungsZustand zustand={v.zustand} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{verwendungen.privat.map((v) => (
|
||||||
|
<li
|
||||||
|
key={v.id}
|
||||||
|
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<a
|
||||||
|
href={`/schluessel/${v.schluessel.id}`}
|
||||||
|
className="text-text hover:text-accent font-medium"
|
||||||
|
>
|
||||||
|
{v.schluessel.name}
|
||||||
|
</a>
|
||||||
|
<span className="text-muted block font-mono text-[12.5px] break-all">
|
||||||
|
Private · {v.zielbenutzer}@{host.name}:{v.pfad}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<VerwendungsZustand zustand={v.zustand} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{darfAusrollen && verwendungen.oeffentlich.length > 0 && host.bootstrapAm && (
|
||||||
|
<div className="border-border mt-5 border-t pt-5">
|
||||||
|
<AusrollenPanel
|
||||||
|
auswahl={{ hostId: host.id }}
|
||||||
|
offen={verwendungen.oeffentlich.filter((v) => v.zustand !== "AUSGEROLLT").length}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
|
|
||||||
{verwalten && (
|
{verwalten && (
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { KeyEntfernen, KeyHinterlegen } from "@/components/personen/MeineSchluessel";
|
||||||
|
import { StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { datum } from "@/lib/format";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { eigeneSchluessel } from "@/server/services/meine-schluessel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Meine Schlüssel" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-Service (#013): eigene Public Keys hinterlegen und – solange nirgends zugewiesen –
|
||||||
|
* wieder entfernen. Beim ersten Aufruf entsteht die eigene Person (oder wird verknuepft).
|
||||||
|
*/
|
||||||
|
export default async function MeineSchluesselSeite() {
|
||||||
|
const { benutzer } = await sitzungErforderlich("eigeneSchluesselHinterlegen", "/meine-schluessel");
|
||||||
|
const { schluessel } = await eigeneSchluessel(benutzer);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-4xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Meine Schlüssel"
|
||||||
|
untertitel="Ihre Public Keys für die Anmeldung an Servern. Wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte titel="Hinterlegt" untertitel={`${schluessel.length} Schlüssel`}>
|
||||||
|
{schluessel.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
titel="Noch keine Schlüssel"
|
||||||
|
text="Hinterlegen Sie unten Ihren Public Key. Danach kann ein Operator oder Admin ihn den Servern zuweisen, auf die Sie Zugriff brauchen."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{schluessel.map((s) => (
|
||||||
|
<li
|
||||||
|
key={s.id}
|
||||||
|
className="border-border flex flex-col gap-2 border-b py-4 first:pt-0 last:border-b-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-text text-[14.5px] font-medium">{s.name}</p>
|
||||||
|
<MonoKopieren
|
||||||
|
wert={s.fingerprint}
|
||||||
|
beschriftung="Fingerprint"
|
||||||
|
umbrechen
|
||||||
|
className="text-[12.5px]"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
{s.verwendungen > 0 ? (
|
||||||
|
<Badge variante="ok">
|
||||||
|
{s.verwendungen === 1 ? "1 Host-Zuweisung" : `${s.verwendungen} Host-Zuweisungen`}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variante="messing">noch nicht zugewiesen</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-muted text-[13px]">seit {datum(s.angelegtAm)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.selbstHinterlegt && s.verwendungen === 0 && (
|
||||||
|
<KeyEntfernen schluesselId={s.id} name={s.name} />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<KeyHinterlegen />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -136,7 +136,7 @@ export default async function Uebersicht() {
|
|||||||
return (
|
return (
|
||||||
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
||||||
<Link
|
<Link
|
||||||
href="/schluessel/beispiel"
|
href="/schluessel"
|
||||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { PersonFormular } from "@/components/personen/PersonFormular";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { kontenAuswahl, personDetail } from "@/server/queries/personen";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Person bearbeiten" };
|
||||||
|
|
||||||
|
export default async function PersonBearbeiten({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
await sitzungErforderlich("schluesselVerwalten", `/personen/${id}/bearbeiten`);
|
||||||
|
const [person, konten] = await Promise.all([personDetail(id), kontenAuswahl(id)]);
|
||||||
|
if (!person || person.archiviertAm) notFound();
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb
|
||||||
|
pfad={[
|
||||||
|
{ titel: "Personen", href: "/personen" },
|
||||||
|
{ titel: person.name, href: `/personen/${person.id}` },
|
||||||
|
{ titel: "Bearbeiten" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
titel="Person bearbeiten"
|
||||||
|
/>
|
||||||
|
<PersonFormular
|
||||||
|
vorgabe={{
|
||||||
|
id: person.id,
|
||||||
|
name: person.name,
|
||||||
|
email: person.email ?? "",
|
||||||
|
organisation: person.organisation ?? "",
|
||||||
|
notiz: person.notiz ?? "",
|
||||||
|
benutzerId: person.benutzerId ?? "",
|
||||||
|
}}
|
||||||
|
konten={konten}
|
||||||
|
abbrechenZiel={`/personen/${person.id}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { KeyRound, Pencil, Plus } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { PersonArchivieren } from "@/components/personen/PersonFormular";
|
||||||
|
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
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 { datum } from "@/lib/format";
|
||||||
|
import { fingerprintKurz } from "@/lib/schluessel";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personDetail } from "@/server/queries/personen";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Person" };
|
||||||
|
|
||||||
|
function Info({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||||
|
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persondetail (#013) mit ihren Schlüsseln. */
|
||||||
|
export default async function PersonSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", `/personen/${id}`);
|
||||||
|
const person = await personDetail(id);
|
||||||
|
if (!person) notFound();
|
||||||
|
const archiviert = person.archiviertAm !== null;
|
||||||
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten") && !archiviert;
|
||||||
|
const jetzt = new Date();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Personen", href: "/personen" }, { titel: person.name }]} />}
|
||||||
|
titel={person.name}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
{archiviert && person.archiviertAm && (
|
||||||
|
<Badge variante="gefahr">archiviert am {datum(person.archiviertAm)}</Badge>
|
||||||
|
)}
|
||||||
|
{person.organisation && <Badge>{person.organisation}</Badge>}
|
||||||
|
{person.benutzer && <Badge variante="ok">Konto verknüpft</Badge>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button href={`/personen/${person.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte as="div">
|
||||||
|
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 min-[480px]:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<Info titel="E-Mail">{person.email ?? <span className="text-muted">–</span>}</Info>
|
||||||
|
<Info titel="Konto">
|
||||||
|
{person.benutzer ? (
|
||||||
|
<>
|
||||||
|
{person.benutzer.name}
|
||||||
|
{person.benutzer.email && (
|
||||||
|
<span className="text-muted block text-[13px]">{person.benutzer.email}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">nicht verknüpft</span>
|
||||||
|
)}
|
||||||
|
</Info>
|
||||||
|
<Info titel="Angelegt">{datum(person.angelegtAm)}</Info>
|
||||||
|
</dl>
|
||||||
|
{person.notiz && (
|
||||||
|
<p className="border-border text-muted mt-5 border-t pt-4 text-[14px] whitespace-pre-line">
|
||||||
|
{person.notiz}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Schlüssel"
|
||||||
|
untertitel={`${person.schluessel.length} Schlüssel dieser Person`}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button
|
||||||
|
href={`/schluessel/neu?person=${person.id}`}
|
||||||
|
klein
|
||||||
|
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||||
|
>
|
||||||
|
Public Key hinterlegen
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{person.schluessel.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
titel="Noch keine Schlüssel"
|
||||||
|
text="Die Person kann ihre Public Keys auch selbst unter „Meine Schlüssel“ hinterlegen, sobald ihr Konto verknüpft ist."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col">
|
||||||
|
{person.schluessel.map((s) => (
|
||||||
|
<li key={s.id} className="border-border border-b py-3 first:pt-0 last:border-b-0 last:pb-0">
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
className="text-text hover:text-accent text-[14.5px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted block font-mono text-[12.5px]">
|
||||||
|
{fingerprintKurz(s.fingerprint)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
||||||
|
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||||
|
<span className="text-muted text-[13px]">
|
||||||
|
{s.verwendungen === 0
|
||||||
|
? "· noch keinem Host zugewiesen"
|
||||||
|
: `· ${s.verwendungen} ${s.verwendungen === 1 ? "Verwendung" : "Verwendungen"}`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{verwalten && (
|
||||||
|
<Karte titel="Archivieren" untertitel="Person stilllegen, ohne die Historie zu verlieren">
|
||||||
|
<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]">
|
||||||
|
Archivierte Personen verschwinden aus Listen. Ihre Schlüssel bleiben bestehen und lassen sich
|
||||||
|
weiterhin widerrufen.
|
||||||
|
</p>
|
||||||
|
<PersonArchivieren personId={person.id} name={person.name} />
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LEERE_PERSON, PersonFormular } from "@/components/personen/PersonFormular";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { kontenAuswahl } from "@/server/queries/personen";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Person anlegen" };
|
||||||
|
|
||||||
|
export default async function PersonNeu() {
|
||||||
|
await sitzungErforderlich("schluesselVerwalten", "/personen/neu");
|
||||||
|
const konten = await kontenAuswahl();
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Personen", href: "/personen" }, { titel: "Neue Person" }]} />}
|
||||||
|
titel="Person anlegen"
|
||||||
|
untertitel="Mitarbeitende, Admins oder Dienstleister, denen Personen-Keys gehören."
|
||||||
|
/>
|
||||||
|
<PersonFormular vorgabe={LEERE_PERSON} konten={konten} abbrechenZiel="/personen" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,137 @@
|
|||||||
|
import { Plus, Search, Users } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import Link from "next/link";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Feld } from "@/components/ui/Feld";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personenListe } from "@/server/queries/personen";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Personen" };
|
export const metadata: Metadata = { title: "Personen" };
|
||||||
|
|
||||||
export default async function Personen() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
function einzeln(wert: string | string[] | undefined): string {
|
||||||
|
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Anzahl({ n }: { n: number }) {
|
||||||
|
return n === 0 ? <span className="text-muted">keine</span> : <>{n}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Personenliste (#013): wem welche Schlüssel gehören. */
|
||||||
|
export default async function Personen({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
await sitzungErforderlich();
|
const { benutzer } = await sitzungErforderlich("lesen", "/personen");
|
||||||
|
const q = einzeln((await searchParams)["q"]);
|
||||||
|
const personen = await personenListe({ q });
|
||||||
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
const anlegen = verwalten && (
|
||||||
|
<Button href="/personen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Person anlegen
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite titel="Personen" untertitel="Wem welche Schlüssel gehören" phase={2} issue="#013" />
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Personen"
|
||||||
|
untertitel={q ? `${personen.length} Treffer` : "Wem welche Schlüssel gehören"}
|
||||||
|
aktionen={anlegen}
|
||||||
|
/>
|
||||||
|
<form method="get" role="search" className="grid items-end gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Name, E-Mail oder Organisation"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||||
|
Suchen
|
||||||
|
</Button>
|
||||||
|
{q && (
|
||||||
|
<Button href="/personen" className="flex-1 sm:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{personen.length === 0 ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Users className="size-5" />}
|
||||||
|
titel={q ? "Keine Treffer" : "Noch keine Personen"}
|
||||||
|
text={
|
||||||
|
q
|
||||||
|
? "Keine Person passt zur Suche."
|
||||||
|
: "Personen entstehen hier oder von selbst, wenn jemand unter „Meine Schlüssel“ einen Key hinterlegt."
|
||||||
|
}
|
||||||
|
aktion={q ? <Button href="/personen">Suche zurücksetzen</Button> : anlegen}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Karte>
|
||||||
|
<ul className="flex flex-col md:hidden">
|
||||||
|
{personen.map((p) => (
|
||||||
|
<li key={p.id} className="border-border border-b last:border-b-0">
|
||||||
|
<Link
|
||||||
|
href={`/personen/${p.id}`}
|
||||||
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-1 px-2 py-3 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-text text-[14.5px] font-medium">{p.name}</span>
|
||||||
|
{p.email && <span className="text-muted truncate text-[13px]">{p.email}</span>}
|
||||||
|
<span className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
|
{p.organisation && <Badge>{p.organisation}</Badge>}
|
||||||
|
{p.benutzer && <Badge variante="ok">Konto verknüpft</Badge>}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-[13px]">
|
||||||
|
Aktive Schlüssel: <Anzahl n={p.aktiveSchluessel} />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Tabelle
|
||||||
|
className="hidden md:block"
|
||||||
|
beschriftung="Personen"
|
||||||
|
spalten={[
|
||||||
|
{ titel: "Name" },
|
||||||
|
{ titel: "E-Mail" },
|
||||||
|
{ titel: "Organisation" },
|
||||||
|
{ titel: "Konto" },
|
||||||
|
{ titel: "Aktive Schlüssel" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<TabellenZeile key={p.id}>
|
||||||
|
<TabellenZelle>
|
||||||
|
<Link
|
||||||
|
href={`/personen/${p.id}`}
|
||||||
|
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center text-[14px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</Link>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="text-muted">{p.email ?? "–"}</TabellenZelle>
|
||||||
|
<TabellenZelle>{p.organisation ?? <span className="text-muted">–</span>}</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
{p.benutzer ? p.benutzer.name : <span className="text-muted">–</span>}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Anzahl n={p.aktiveSchluessel} />
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AusrollDialog } from "@/components/ausrollen/AusrollDialog";
|
||||||
|
import { PrivateZuweisenFormular } from "@/components/ausrollen/PrivateZuweisenFormular";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
|
||||||
|
/** „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#017) – nur Admins. */
|
||||||
|
export function PrivateAusrollenAktion({
|
||||||
|
schluesselId,
|
||||||
|
darf,
|
||||||
|
auswahl,
|
||||||
|
slug,
|
||||||
|
passphraseGeschuetzt = false,
|
||||||
|
}: {
|
||||||
|
schluesselId: string;
|
||||||
|
darf: boolean;
|
||||||
|
auswahl?: ZielAuswahl;
|
||||||
|
slug?: string;
|
||||||
|
passphraseGeschuetzt?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!darf || !auswahl || !slug) return null;
|
||||||
|
return (
|
||||||
|
<AusrollDialog titel="Private Key ausrollen">
|
||||||
|
{(schliessen) => (
|
||||||
|
<PrivateZuweisenFormular
|
||||||
|
schluesselId={schluesselId}
|
||||||
|
slug={slug}
|
||||||
|
auswahl={auswahl}
|
||||||
|
passphraseGeschuetzt={passphraseGeschuetzt}
|
||||||
|
beiErfolg={schliessen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AusrollDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Download, Eye, Trash2, TriangleAlert } from "lucide-react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
|
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
|
||||||
|
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||||||
|
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
|
||||||
|
import {
|
||||||
|
abholungAnfordernAktion,
|
||||||
|
privateKeyAnzeigenAktion,
|
||||||
|
privateKeyLoeschenAktion,
|
||||||
|
type PrivateKeyAntwort,
|
||||||
|
} from "@/server/actions/privatekey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private Key anzeigen, herunterladen und loeschen (#014). Alles verlangt frische
|
||||||
|
* Bestaetigung; fehlt sie, geht es nach /bestaetigen und von dort mit `?privatekey=1` zurueck,
|
||||||
|
* damit der Dialog gleich wieder offen ist.
|
||||||
|
*
|
||||||
|
* Der Key lebt nur im Zustand dieses Dialogs und wird beim Schliessen verworfen.
|
||||||
|
*/
|
||||||
|
const DIALOG_KLASSEN =
|
||||||
|
"border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-2xl border p-5 backdrop:bg-black/70 sm:p-6";
|
||||||
|
|
||||||
|
function useBestaetigungUmleitung() {
|
||||||
|
const router = useRouter();
|
||||||
|
const pfad = usePathname();
|
||||||
|
return (antwort: PrivateKeyAntwort): boolean => {
|
||||||
|
if (!antwort.bestaetigen) return false;
|
||||||
|
router.push(`/bestaetigen?ziel=${encodeURIComponent(`${pfad}?privatekey=1`)}`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrivateKeyAnzeigen({ schluesselId, name }: { schluesselId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const download = useRef<HTMLFormElement>(null);
|
||||||
|
const suche = useSearchParams();
|
||||||
|
const umleiten = useBestaetigungUmleitung();
|
||||||
|
const [zweck, setZweck] = useState<"anzeigen" | "herunterladen">();
|
||||||
|
const [vorgangId, setVorgangId] = useState<string>();
|
||||||
|
const [privateKey, setPrivateKey] = useState<string>();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [startet, starten] = useTransition();
|
||||||
|
const { stand, abfrageFehler } = useVorgang(vorgangId);
|
||||||
|
const abgeholt = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
|
// Zurueck von /bestaetigen: Dialog gleich oeffnen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (suche.get("privatekey") === "1") dialog.current?.showModal();
|
||||||
|
}, [suche]);
|
||||||
|
|
||||||
|
// Bereit? Dann genau einmal abholen – als Anzeige oder als Download-Formular.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vorgangId || stand?.status !== "ERLEDIGT" || abgeholt.current === vorgangId) return;
|
||||||
|
abgeholt.current = vorgangId;
|
||||||
|
if (zweck === "herunterladen") {
|
||||||
|
download.current?.submit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void privateKeyAnzeigenAktion(vorgangId).then((antwort) => {
|
||||||
|
if (umleiten(antwort)) return;
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else setPrivateKey(antwort.privateKey);
|
||||||
|
});
|
||||||
|
}, [vorgangId, stand?.status, zweck, umleiten]);
|
||||||
|
|
||||||
|
function anfordern(art: "anzeigen" | "herunterladen") {
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
setPrivateKey(undefined);
|
||||||
|
setZweck(art);
|
||||||
|
const antwort = await abholungAnfordernAktion(schluesselId, art);
|
||||||
|
if (umleiten(antwort)) return;
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else setVorgangId(antwort.vorgangId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schliessen() {
|
||||||
|
// Nichts vom Key bleibt im Zustand stehen.
|
||||||
|
setPrivateKey(undefined);
|
||||||
|
setVorgangId(undefined);
|
||||||
|
setFehler(undefined);
|
||||||
|
dialog.current?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
|
||||||
|
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
|
||||||
|
const downloadGestartet =
|
||||||
|
zweck === "herunterladen" && vorgangId !== undefined && stand?.status === "ERLEDIGT";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
|
||||||
|
>
|
||||||
|
anzeigen (Re-Auth)
|
||||||
|
</button>
|
||||||
|
<dialog ref={dialog} aria-labelledby="privatekey-titel" className={DIALOG_KLASSEN} onClose={schliessen}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 id="privatekey-titel" className="text-[17px] font-semibold">
|
||||||
|
Private Key von „{name}“
|
||||||
|
</h2>
|
||||||
|
{privateKey ? (
|
||||||
|
<>
|
||||||
|
<MonoBlock wert={privateKey} beschriftung="Private Key" hoehe="22rem" />
|
||||||
|
<p className="text-accent flex items-start gap-2 text-[14px]">
|
||||||
|
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||||
|
Wird nicht erneut angezeigt. Kopieren Sie ihn jetzt an einen sicheren Ort.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : downloadGestartet ? (
|
||||||
|
<p role="status" className="text-ok text-[14.5px]">
|
||||||
|
Download gestartet. Der Private Key wird nicht erneut angeboten.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
Der Private Key wird im Worker entschlüsselt und genau einmal ausgeliefert. Jede Anzeige und
|
||||||
|
jeder Download steht im Audit-Log.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<Eye className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => anfordern("anzeigen")}
|
||||||
|
>
|
||||||
|
{laeuft && zweck === "anzeigen" ? "Entschlüssele …" : "Anzeigen"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<Download className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => anfordern("herunterladen")}
|
||||||
|
>
|
||||||
|
{laeuft && zweck === "herunterladen" ? "Entschlüssele …" : "Herunterladen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={schliessen}>Schließen</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{vorgangId && <form ref={download} method="post" action={`/api/private-key/${vorgangId}`} hidden />}
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „Private Key aus SSHKeyVault loeschen“ (#014) – endgueltig, mit Rueckfrage. */
|
||||||
|
export function PrivateKeyLoeschen({ schluesselId, name }: { schluesselId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const umleiten = useBestaetigungUmleitung();
|
||||||
|
const [verstanden, setVerstanden] = useState(false);
|
||||||
|
const [vorgangId, setVorgangId] = useState<string>();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [startet, starten] = useTransition();
|
||||||
|
const { stand, abfrageFehler } = useVorgang(vorgangId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (stand?.status === "ERLEDIGT") {
|
||||||
|
dialog.current?.close();
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}, [stand?.status, router]);
|
||||||
|
|
||||||
|
const laeuft = startet || (vorgangId !== undefined && istLaufend(stand) && !abfrageFehler);
|
||||||
|
const vorgangsFehler = stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Trash2 className="size-4" />}
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
>
|
||||||
|
Private Key löschen
|
||||||
|
</Button>
|
||||||
|
<dialog ref={dialog} aria-labelledby="privatekey-loeschen-titel" className={DIALOG_KLASSEN}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 id="privatekey-loeschen-titel" className="text-[17px] font-semibold">
|
||||||
|
Private Key von „{name}“ aus SSHKeyVault löschen?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
sshkv verwirft den verschlüsselten Private Key endgültig. Der Public Key und alle Zuweisungen
|
||||||
|
bleiben; auf den Hosts ändert sich nichts. Danach kann sshkv den Private Key weder anzeigen noch
|
||||||
|
auf weitere Hosts ausrollen.
|
||||||
|
</p>
|
||||||
|
<Ankreuzfeld
|
||||||
|
checked={verstanden}
|
||||||
|
onChange={(e) => setVerstanden(e.target.checked)}
|
||||||
|
beschriftung="Ich habe verstanden, dass sich das nicht rückgängig machen lässt."
|
||||||
|
/>
|
||||||
|
<Formularfehler text={fehler ?? abfrageFehler ?? vorgangsFehler} />
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
disabled={!verstanden || laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
const antwort = await privateKeyLoeschenAktion(schluesselId);
|
||||||
|
if (umleiten(antwort)) return;
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else setVorgangId(antwort.vorgangId);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laeuft ? "Lösche …" : "Endgültig löschen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AusrollDialog } from "@/components/ausrollen/AusrollDialog";
|
||||||
|
import { PublicZuweisenFormular } from "@/components/ausrollen/PublicZuweisenFormular";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
|
||||||
|
/** „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#016). */
|
||||||
|
export function PublicAusrollenAktion({
|
||||||
|
schluesselId,
|
||||||
|
darf,
|
||||||
|
auswahl,
|
||||||
|
}: {
|
||||||
|
schluesselId: string;
|
||||||
|
darf: boolean;
|
||||||
|
auswahl?: ZielAuswahl;
|
||||||
|
}) {
|
||||||
|
if (!darf || !auswahl) return null;
|
||||||
|
return (
|
||||||
|
<AusrollDialog titel="Public Key zuweisen">
|
||||||
|
{(schliessen) => (
|
||||||
|
<PublicZuweisenFormular schluesselId={schluesselId} auswahl={auswahl} beiErfolg={schliessen} />
|
||||||
|
)}
|
||||||
|
</AusrollDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { SchluesselBearbeiten } from "@/components/schluessel/SchluesselBearbeiten";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personenAuswahl, schluesselDetail, vorlagenAuswahl } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Schlüssel bearbeiten" };
|
||||||
|
|
||||||
|
export default async function SchluesselBearbeitenSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/bearbeiten`);
|
||||||
|
const [s, personen, vorlagen] = await Promise.all([
|
||||||
|
schluesselDetail(id),
|
||||||
|
personenAuswahl(),
|
||||||
|
vorlagenAuswahl(),
|
||||||
|
]);
|
||||||
|
if (!s) notFound();
|
||||||
|
|
||||||
|
// Eine inzwischen archivierte Person oder Vorlage bleibt waehlbar, solange sie zugeordnet
|
||||||
|
// ist – sonst loeschte schon das blosse Speichern die Zuordnung.
|
||||||
|
const personListe = personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }));
|
||||||
|
if (s.person && !personListe.some((p) => p.id === s.person?.id))
|
||||||
|
personListe.push({ ...s.person, zusatz: null });
|
||||||
|
const vorlagenListe = vorlagen.map((v) => ({ id: v.id, name: v.name }));
|
||||||
|
if (s.vorlage && !vorlagenListe.some((v) => v.id === s.vorlage?.id)) vorlagenListe.push(s.vorlage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb
|
||||||
|
pfad={[
|
||||||
|
{ titel: "Schlüssel", href: "/schluessel" },
|
||||||
|
{ titel: s.name, href: `/schluessel/${s.id}` },
|
||||||
|
{ titel: "Bearbeiten" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
titel="Schlüssel bearbeiten"
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
Kurzname <span className="text-text font-mono">{s.slug}</span> und Kommentar bleiben unverändert.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SchluesselBearbeiten
|
||||||
|
werte={{
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
tags: s.tags,
|
||||||
|
ablaufAm: s.ablaufAm ? s.ablaufAm.toISOString().slice(0, 10) : "",
|
||||||
|
personId: s.personId ?? "",
|
||||||
|
vorlageId: s.vorlageId ?? "",
|
||||||
|
status: s.status,
|
||||||
|
statusAenderbar:
|
||||||
|
s.kategorie !== "DEPLOYMENT" && (s.status === "AKTIV" || s.status === "ABGELAUFEN"),
|
||||||
|
}}
|
||||||
|
personen={personListe}
|
||||||
|
vorlagen={vorlagenListe}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import { Download, Lock, LockOpen, Pencil } from "lucide-react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { InnereBox, Karte } from "@/components/ui/Karte";
|
||||||
|
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||||||
|
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
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 { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||||
|
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||||
|
import { zielAuswahl } from "@/server/queries/ziele";
|
||||||
|
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||||
|
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||||
|
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Schlüssel" };
|
||||||
|
|
||||||
|
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0 px-5 py-4 sm:px-6">
|
||||||
|
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
||||||
|
<dd className="min-w-0">{children}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soll-Zustand einer Verwendung als Statuspunkt – Form und Text tragen die Information mit. */
|
||||||
|
const ZUSTAND: Record<string, { status: HostStatus; text: string }> = {
|
||||||
|
GEPLANT: { status: "unbekannt", text: "geplant" },
|
||||||
|
AUSGEROLLT: { status: "synchron", text: "ausgerollt" },
|
||||||
|
ABWEICHUNG: { status: "drift", text: "Abweichung" },
|
||||||
|
ENTFERNEN: { status: "unbekannt", text: "wird entfernt" },
|
||||||
|
FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type Eintrag = {
|
||||||
|
id: string;
|
||||||
|
ziel: string;
|
||||||
|
href?: string;
|
||||||
|
pfad: string;
|
||||||
|
zusatz?: string;
|
||||||
|
zustand: string;
|
||||||
|
aktionen?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AusrollKarte({
|
||||||
|
titel,
|
||||||
|
untertitel,
|
||||||
|
eintraege,
|
||||||
|
leer,
|
||||||
|
aktion,
|
||||||
|
}: {
|
||||||
|
titel: string;
|
||||||
|
untertitel: string;
|
||||||
|
eintraege: Eintrag[];
|
||||||
|
leer: string;
|
||||||
|
aktion: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Karte titel={titel} untertitel={untertitel} aktionen={aktion}>
|
||||||
|
{eintraege.length === 0 ? (
|
||||||
|
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-6 text-center text-[14px]">
|
||||||
|
{leer}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-3">
|
||||||
|
{eintraege.map((e) => {
|
||||||
|
const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand };
|
||||||
|
return (
|
||||||
|
<li key={e.id}>
|
||||||
|
<InnereBox className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-text font-mono text-[13.5px] font-medium break-all">
|
||||||
|
{e.href ? (
|
||||||
|
<Link href={e.href} className="hover:text-accent transition-colors">
|
||||||
|
{e.ziel}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
e.ziel
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
||||||
|
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
||||||
|
</div>
|
||||||
|
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
|
||||||
|
</div>
|
||||||
|
{/* Aktionen unter dem Eintrag, damit lange Hostnamen nicht umbrechen (#016). */}
|
||||||
|
{e.aktionen}
|
||||||
|
</InnereBox>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function privateEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] {
|
||||||
|
return s.privateVerwendungen.map((v) => {
|
||||||
|
const config =
|
||||||
|
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
ziel: `${v.zielbenutzer}@${v.host.name}`,
|
||||||
|
href: `/hosts/${v.host.id}`,
|
||||||
|
pfad: v.pfad,
|
||||||
|
...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}),
|
||||||
|
zustand: v.zustand,
|
||||||
|
aktionen: (
|
||||||
|
<EintragAktionen
|
||||||
|
art="privat"
|
||||||
|
verwendungId={v.id}
|
||||||
|
schluesselId={s.id}
|
||||||
|
darfEntfernen={rechte.privat && v.zustand !== "ENTFERNEN"}
|
||||||
|
testbar={rechte.testen && v.zustand === "AUSGEROLLT" && typeof config?.alias === "string"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rechte = { readonly public: boolean; readonly privat: boolean; readonly testen: boolean };
|
||||||
|
|
||||||
|
function publicEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] {
|
||||||
|
return s.publicVerwendungen.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
ziel: v.host
|
||||||
|
? `${v.zielbenutzer}@${v.host.name}`
|
||||||
|
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
|
||||||
|
...(v.host
|
||||||
|
? { href: `/hosts/${v.host.id}` }
|
||||||
|
: v.hostgruppe
|
||||||
|
? { href: `/hostgruppen/${v.hostgruppe.id}` }
|
||||||
|
: {}),
|
||||||
|
pfad: "~/.ssh/authorized_keys",
|
||||||
|
...(v.optionen ? { zusatz: v.optionen } : {}),
|
||||||
|
zustand: v.zustand,
|
||||||
|
aktionen: (
|
||||||
|
<EintragAktionen
|
||||||
|
art="public"
|
||||||
|
verwendungId={v.id}
|
||||||
|
schluesselId={s.id}
|
||||||
|
darfEntfernen={rechte.public && v.zustand !== "ENTFERNEN"}
|
||||||
|
testbar={false}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was mit dem Private Key ist – ohne ihn je zu lesen (masterKeyVersion verraet es). */
|
||||||
|
function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNode }) {
|
||||||
|
if (s.privatGespeichert) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
||||||
|
<Lock className="text-ok size-3.5" aria-hidden />
|
||||||
|
verschlüsselt gespeichert
|
||||||
|
{s.passphraseGeschuetzt && <span className="text-muted">· mit Passphrase</span>}
|
||||||
|
{anzeigen}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (s.privatGeloeschtAm) {
|
||||||
|
return (
|
||||||
|
<span className="text-muted inline-flex items-center gap-1.5 text-[14px]">
|
||||||
|
<LockOpen className="size-3.5" aria-hidden />
|
||||||
|
gelöscht am <time dateTime={s.privatGeloeschtAm.toISOString()}>{datum(s.privatGeloeschtAm)}</time>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="text-muted text-[14px]">
|
||||||
|
nicht gespeichert{s.kategorie === "PERSON" ? " (Personen-Key)" : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
|
||||||
|
export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
|
||||||
|
const s = await schluesselDetail(id);
|
||||||
|
if (!s) notFound();
|
||||||
|
|
||||||
|
const aktiv = s.status === "AKTIV";
|
||||||
|
const rechte: Rechte = {
|
||||||
|
public: darf(benutzer.rolle, "publicKeyAusrollen"),
|
||||||
|
privat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
|
testen: darf(benutzer.rolle, "hostsVerwalten"),
|
||||||
|
};
|
||||||
|
const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined;
|
||||||
|
const personenKey = s.kategorie === "PERSON";
|
||||||
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
// Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst.
|
||||||
|
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">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.name }]} />}
|
||||||
|
titel={s.name}
|
||||||
|
untertitel={
|
||||||
|
s.person ? (
|
||||||
|
<>
|
||||||
|
Person:{" "}
|
||||||
|
<Link href={`/personen/${s.person.id}`} className="text-accent hover:underline">
|
||||||
|
{s.person.name}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
|
||||||
|
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
||||||
|
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
|
||||||
|
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} lang />
|
||||||
|
{s.tags.map((t) => (
|
||||||
|
<Badge key={t} mono className="text-muted">
|
||||||
|
#{t}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
verwalten && (
|
||||||
|
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
||||||
|
</InfoFeld>
|
||||||
|
<InfoFeld titel="Erstellt">
|
||||||
|
<time dateTime={s.angelegtAm.toISOString()} className="font-mono text-[13px]">
|
||||||
|
{datumZeit(s.angelegtAm)}
|
||||||
|
</time>
|
||||||
|
<span className="text-muted block text-[13px]">
|
||||||
|
{s.erstelltVon ? `von ${s.erstelltVon.name}` : "von sshkv"}
|
||||||
|
</span>
|
||||||
|
</InfoFeld>
|
||||||
|
<InfoFeld titel="Private Key">
|
||||||
|
<PrivatStatus
|
||||||
|
s={s}
|
||||||
|
anzeigen={
|
||||||
|
darfAnzeigen && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<PrivateKeyAnzeigen schluesselId={s.id} name={s.name} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InfoFeld>
|
||||||
|
</dl>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{/* Personen-Keys haben nie einen gespeicherten Private Key (DB-CHECK) – die Karte dafuer
|
||||||
|
waere immer leer. */}
|
||||||
|
<div className={personenKey ? "grid gap-6" : "grid gap-6 lg:grid-cols-2"}>
|
||||||
|
{!personenKey && (
|
||||||
|
<AusrollKarte
|
||||||
|
titel="Private Key ausgerollt auf"
|
||||||
|
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||||||
|
eintraege={privateEintraege(s, rechte)}
|
||||||
|
leer={
|
||||||
|
s.privatGespeichert
|
||||||
|
? "Der Private Key liegt noch auf keinem Host."
|
||||||
|
: "Ohne gespeicherten Private Key kann sshkv ihn nicht ausrollen."
|
||||||
|
}
|
||||||
|
aktion={
|
||||||
|
<PrivateAusrollenAktion
|
||||||
|
schluesselId={s.id}
|
||||||
|
darf={aktiv && s.privatGespeichert && rechte.privat}
|
||||||
|
{...(auswahl ? { auswahl } : {})}
|
||||||
|
slug={s.slug}
|
||||||
|
passphraseGeschuetzt={s.passphraseGeschuetzt}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AusrollKarte
|
||||||
|
titel="Public Key ausgerollt auf"
|
||||||
|
untertitel="Konten, die diesen Schlüssel annehmen"
|
||||||
|
eintraege={publicEintraege(s, rechte)}
|
||||||
|
leer={
|
||||||
|
personenKey
|
||||||
|
? "Noch keinem Konto zugewiesen – wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
||||||
|
: "Noch keinem Konto zugewiesen."
|
||||||
|
}
|
||||||
|
aktion={
|
||||||
|
<PublicAusrollenAktion
|
||||||
|
schluesselId={s.id}
|
||||||
|
darf={aktiv && rechte.public}
|
||||||
|
{...(auswahl ? { auswahl } : {})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rechte.public &&
|
||||||
|
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
|
||||||
|
<Karte
|
||||||
|
titel="Dry-Run-Vorschau"
|
||||||
|
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
|
||||||
|
>
|
||||||
|
<AusrollenPanel
|
||||||
|
auswahl={{ schluesselId: s.id }}
|
||||||
|
offen={
|
||||||
|
s.publicVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length +
|
||||||
|
(rechte.privat ? s.privateVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length : 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Karte
|
||||||
|
titel="Public Key"
|
||||||
|
untertitel={
|
||||||
|
<>
|
||||||
|
Kommentar: <span className="font-mono">{s.kommentar || "–"}</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
aktionen={
|
||||||
|
<a
|
||||||
|
href={`/api/schluessel/${s.id}/public-key`}
|
||||||
|
download={`${s.slug}.pub`}
|
||||||
|
className="border-border-strong bg-surface text-text hover:bg-raised rounded-box inline-flex min-h-11 items-center gap-2 border px-3 text-[13px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="size-4" aria-hidden />
|
||||||
|
{s.slug}.pub
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MonoBlock wert={s.publicKey} beschriftung="Public Key" umbrechen />
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{darfLoeschen && (
|
||||||
|
<Karte titel="Private Key löschen" untertitel="Nur noch auf den Hosts, nicht mehr in SSHKeyVault">
|
||||||
|
<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]">
|
||||||
|
Sinnvoll, sobald der Private Key ausgerollt ist und sshkv ihn nicht mehr braucht. Danach kann
|
||||||
|
ihn niemand mehr aus SSHKeyVault holen – auch kein Admin.
|
||||||
|
</p>
|
||||||
|
<PrivateKeyLoeschen schluesselId={s.id} name={s.name} />
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Ban, Check, Lock, PlugZap, Plus, RotateCw } from "lucide-react";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
beispielSchluessel as s,
|
|
||||||
dryRunDatei,
|
|
||||||
dryRunDiff,
|
|
||||||
privatAusgerollt,
|
|
||||||
publicAusgerollt,
|
|
||||||
zusatzaktionen,
|
|
||||||
type Ausrollung,
|
|
||||||
} from "@/beispiel/schluessel";
|
|
||||||
import { BEISPIEL_JETZT } from "@/beispiel/uebersicht";
|
|
||||||
import { Badge } from "@/components/ui/Badge";
|
|
||||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
import { DiffBox } from "@/components/ui/DiffBox";
|
|
||||||
import { InnereBox, Karte } from "@/components/ui/Karte";
|
|
||||||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
|
||||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
|
||||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
|
||||||
import { datum, datumZeit, tageBis } from "@/lib/format";
|
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
|
||||||
|
|
||||||
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
|
|
||||||
|
|
||||||
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
|
|
||||||
export default async function SchluesselBeispiel() {
|
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
|
||||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6 sm:gap-7">
|
|
||||||
<Seitenkopf
|
|
||||||
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.zweck }]} />}
|
|
||||||
titel={s.zweck}
|
|
||||||
badges={
|
|
||||||
<>
|
|
||||||
<Badge mono>{s.typ}</Badge>
|
|
||||||
<Badge>{s.kategorie}</Badge>
|
|
||||||
<Badge>Vorlage: {s.vorlage}</Badge>
|
|
||||||
<Badge variante="messing">
|
|
||||||
<span>
|
|
||||||
läuft ab am <time dateTime={s.ablauf}>{datum(s.ablauf)}</time> · in {tage} Tagen
|
|
||||||
</span>
|
|
||||||
</Badge>
|
|
||||||
<Badge variante="neutral" className="text-muted">
|
|
||||||
Beispieldaten
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
aktionen={
|
|
||||||
<>
|
|
||||||
<Button icon={<PlugZap className="size-4" />}>Verbindung testen</Button>
|
|
||||||
<Button icon={<RotateCw className="size-4" />}>Rotieren</Button>
|
|
||||||
<Button variante="gefahr" icon={<Ban className="size-4" />}>
|
|
||||||
Widerrufen
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
|
||||||
</InfoFeld>
|
|
||||||
<InfoFeld titel="Erstellt">
|
|
||||||
<time dateTime={s.erstellt} className="font-mono text-[13px]">
|
|
||||||
{datumZeit(s.erstellt)}
|
|
||||||
</time>
|
|
||||||
<span className="text-muted block text-[13px]">von {s.erstelltVon}</span>
|
|
||||||
</InfoFeld>
|
|
||||||
<InfoFeld titel="Private Key">
|
|
||||||
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
|
||||||
<Lock className="text-ok size-3.5" aria-hidden />
|
|
||||||
verschlüsselt gespeichert ·
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
|
|
||||||
>
|
|
||||||
anzeigen (Re-Auth)
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</InfoFeld>
|
|
||||||
</dl>
|
|
||||||
</Karte>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<AusrollKarte
|
|
||||||
titel="Private Key ausgerollt auf"
|
|
||||||
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
|
||||||
eintraege={privatAusgerollt}
|
|
||||||
/>
|
|
||||||
<AusrollKarte
|
|
||||||
titel="Public Key ausgerollt auf"
|
|
||||||
untertitel="Konten, die diesen Schlüssel annehmen"
|
|
||||||
eintraege={publicAusgerollt}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Karte
|
|
||||||
titel="Dry-Run-Vorschau"
|
|
||||||
untertitel={
|
|
||||||
<>
|
|
||||||
Abgleich für <span className="font-mono">backup@srv-pve02</span> – noch nichts geschrieben
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DiffBox datei={dryRunDatei} zeilen={dryRunDiff} />
|
|
||||||
<fieldset className="mt-5">
|
|
||||||
<legend className="text-muted mb-1 text-[13px] font-medium">Zusätzlich ausführen</legend>
|
|
||||||
<ul className="flex flex-col">
|
|
||||||
{zusatzaktionen.map((z) => (
|
|
||||||
<li key={z.id}>
|
|
||||||
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-[14.5px]">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name={z.id}
|
|
||||||
defaultChecked={z.an}
|
|
||||||
className="accent-accent size-[18px] shrink-0"
|
|
||||||
/>
|
|
||||||
<span>{z.text}</span>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</fieldset>
|
|
||||||
<div className="border-border mt-5 flex flex-wrap gap-2.5 border-t pt-5">
|
|
||||||
<Button variante="primaer" icon={<Check className="size-4" strokeWidth={2.4} />}>
|
|
||||||
Änderung anwenden
|
|
||||||
</Button>
|
|
||||||
<Button>Verwerfen</Button>
|
|
||||||
</div>
|
|
||||||
</Karte>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="min-w-0 px-5 py-4 sm:px-6">
|
|
||||||
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
|
||||||
<dd className="min-w-0">{children}</dd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AusrollKarte({
|
|
||||||
titel,
|
|
||||||
untertitel,
|
|
||||||
eintraege,
|
|
||||||
}: {
|
|
||||||
titel: string;
|
|
||||||
untertitel: string;
|
|
||||||
eintraege: Ausrollung[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Karte
|
|
||||||
titel={titel}
|
|
||||||
untertitel={untertitel}
|
|
||||||
aktionen={
|
|
||||||
<Button klein icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
|
||||||
Ausrollen
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ul className="flex flex-col gap-3">
|
|
||||||
{eintraege.map((e) => (
|
|
||||||
<li key={`${e.ziel}${e.pfad}`}>
|
|
||||||
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-text font-mono text-[13.5px] font-medium break-all">{e.ziel}</p>
|
|
||||||
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
|
||||||
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
|
||||||
</div>
|
|
||||||
<Statuspunkt status={e.status} text={e.statusText} className="shrink-0 sm:pt-0.5" />
|
|
||||||
</InnereBox>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Karte>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { SchluesselNeu } from "@/components/schluessel/SchluesselNeu";
|
||||||
|
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personenAuswahl, vorlagenAuswahl } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Schlüssel anlegen" };
|
||||||
|
|
||||||
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
export default async function SchluesselNeuSeite({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
|
await sitzungErforderlich("schluesselVerwalten", "/schluessel/neu");
|
||||||
|
const person = (await searchParams)["person"];
|
||||||
|
const [personen, vorlagen] = await Promise.all([personenAuswahl(), vorlagenAuswahl()]);
|
||||||
|
const vorgabePersonId =
|
||||||
|
typeof person === "string" && personen.some((p) => p.id === person) ? person : undefined;
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
oben={
|
||||||
|
<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: "Neuer Schlüssel" }]} />
|
||||||
|
}
|
||||||
|
titel="Schlüssel anlegen"
|
||||||
|
untertitel="Neu erzeugen oder einen vorhandenen Schlüssel übernehmen."
|
||||||
|
/>
|
||||||
|
<SchluesselNeu
|
||||||
|
personen={personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }))}
|
||||||
|
vorlagen={vorlagen.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
zusatz: v.standardLaufzeitTage ? `${v.standardLaufzeitTage} Tage` : null,
|
||||||
|
}))}
|
||||||
|
{...(vorgabePersonId ? { vorgabePersonId } : {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +1,223 @@
|
|||||||
|
import { KeyRound, Lock, Plus, Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||||
|
import { datum } from "@/lib/format";
|
||||||
|
import { ablaufZustand, fingerprintKurz, KATEGORIE_TEXT, STATUS_TEXT } from "@/lib/schluessel";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { schluesselListe, type SchluesselListenEintrag } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Schlüssel" };
|
export const metadata: Metadata = { title: "Schlüssel" };
|
||||||
|
|
||||||
export default async function Schluessel() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
function einzeln(wert: string | string[] | undefined): string {
|
||||||
|
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function Verwendungen({ anzahl }: { anzahl: number }) {
|
||||||
|
if (anzahl === 0) return <span className="text-muted">keine</span>;
|
||||||
|
return <>{anzahl}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In der Tabelle: Badge, wenn es drängt; sonst nur das Datum. */
|
||||||
|
function Ablauf({ ablaufAm, jetzt }: { ablaufAm: Date | null; jetzt: Date }) {
|
||||||
|
if (!ablaufAm) return <span className="text-muted">–</span>;
|
||||||
|
if (ablaufZustand(ablaufAm, jetzt).art === "spaeter") {
|
||||||
|
return (
|
||||||
|
<time dateTime={ablaufAm.toISOString()} className="text-muted">
|
||||||
|
{datum(ablaufAm)}
|
||||||
|
</time>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <AblaufBadge ablaufAm={ablaufAm} jetzt={jetzt} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) {
|
||||||
|
if (!s.privatGespeichert) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<span title="Private Key verschlüsselt gespeichert" className="text-muted inline-flex">
|
||||||
<Platzhalterseite
|
<Lock className="size-3.5" aria-hidden />
|
||||||
|
<span className="sr-only">Private Key gespeichert</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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).
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", "/schluessel");
|
||||||
|
const parameter = await searchParams;
|
||||||
|
const q = einzeln(parameter["q"]);
|
||||||
|
const kategorie = einzeln(parameter["kategorie"]);
|
||||||
|
const status = einzeln(parameter["status"]);
|
||||||
|
const liste = await schluesselListe({ q, kategorie, status });
|
||||||
|
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
const gefiltert = q !== "" || kategorie !== "" || status !== "";
|
||||||
|
const jetzt = new Date();
|
||||||
|
|
||||||
|
const anlegen = verwalten && (
|
||||||
|
<Button href="/schluessel/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
|
||||||
|
Schlüssel anlegen
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
titel="Schlüssel"
|
titel="Schlüssel"
|
||||||
untertitel="SSH-Schlüssel erzeugen, importieren und ausrollen"
|
untertitel={
|
||||||
phase={2}
|
gefiltert
|
||||||
issue="#012"
|
? `${liste.length} Treffer`
|
||||||
|
: `${liste.length} ${liste.length === 1 ? "verwalteter Schlüssel" : "verwaltete Schlüssel"}`
|
||||||
|
}
|
||||||
|
aktionen={anlegen}
|
||||||
/>
|
/>
|
||||||
<p className="text-muted text-[14px]">
|
|
||||||
Prototyp ansehen:{" "}
|
<form
|
||||||
<Link href="/schluessel/beispiel" className="text-accent font-medium hover:underline">
|
method="get"
|
||||||
Schlüsseldetail mit Beispieldaten
|
role="search"
|
||||||
</Link>
|
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_11rem_auto]"
|
||||||
</p>
|
>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Zweck, Fingerprint, Tag, Person"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="sm:col-span-2 lg:col-span-1"
|
||||||
|
/>
|
||||||
|
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={kategorie}>
|
||||||
|
<option value="">Alle Kategorien</option>
|
||||||
|
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Status" name="status" defaultValue={status}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{Object.entries(STATUS_TEXT).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="flex gap-2.5 sm:col-span-2 lg:col-span-1">
|
||||||
|
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 lg:flex-none">
|
||||||
|
Filtern
|
||||||
|
</Button>
|
||||||
|
{gefiltert && (
|
||||||
|
<Button href="/schluessel" className="flex-1 lg:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{liste.length === 0 ? (
|
||||||
|
gefiltert ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Search className="size-5" />}
|
||||||
|
titel="Keine Treffer"
|
||||||
|
text="Kein Schlüssel passt zu Suche und Filter."
|
||||||
|
aktion={<Button href="/schluessel">Filter zurücksetzen</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<KeyRound className="size-5" />}
|
||||||
|
titel="Noch keine Schlüssel"
|
||||||
|
text="Erzeugen Sie einen Schlüssel oder importieren Sie einen vorhandenen Public Key."
|
||||||
|
aktion={anlegen}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Karte>
|
||||||
|
{/* 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">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
|
||||||
|
<span className="truncate">{s.name}</span>
|
||||||
|
<PrivatHinweis s={s} />
|
||||||
|
</span>
|
||||||
|
<span className="text-muted block truncate font-mono text-[12.5px]">
|
||||||
|
{fingerprintKurz(s.fingerprint)}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-[13px]">
|
||||||
|
Verwendungen: <Verwendungen anzahl={s.verwendungen} />
|
||||||
|
{s.person && <> · {s.person.name}</>}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<Tabelle
|
||||||
|
className="hidden md:block"
|
||||||
|
beschriftung="Schlüssel"
|
||||||
|
spalten={[
|
||||||
|
{ titel: "Name" },
|
||||||
|
{ titel: "Typ" },
|
||||||
|
{ titel: "Kategorie" },
|
||||||
|
{ titel: "Fingerprint" },
|
||||||
|
{ titel: "Ablauf" },
|
||||||
|
{ titel: "Verwendungen" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{liste.map((s) => (
|
||||||
|
<TabellenZeile key={s.id}>
|
||||||
|
<TabellenZelle>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/schluessel/${s.id}`}
|
||||||
|
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center text-[14px] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
<PrivatHinweis s={s} />
|
||||||
|
<StatusBadge status={s.status} />
|
||||||
|
</span>
|
||||||
|
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<KategorieBadge kategorie={s.kategorie} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||||
|
<span title={s.fingerprint}>{fingerprintKurz(s.fingerprint)}</span>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Ablauf ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<Verwendungen anzahl={s.verwendungen} />
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { oeffentlicheAdresse } from "@/lib/adressen";
|
||||||
|
import { akteurVon } from "@/server/actions/formular";
|
||||||
|
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { HostFehler } from "@/server/services/hosts";
|
||||||
|
import { privateKeyAbholen } from "@/server/services/privatekey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download des Private Keys als Datei `<slug>` (#014). Nur POST aus einem Formular dieser
|
||||||
|
* Anwendung: ein GET liesse sich per Link oder Vorabruf ausloesen und verbraeuchte die
|
||||||
|
* einmalige Abholung. Fremde Seiten haelt die Herkunftspruefung fern.
|
||||||
|
*
|
||||||
|
* Prueft selbst Recht und frische Bestaetigung. Antwort ohne Caching; Fehlertexte enthalten
|
||||||
|
* nie Schluesselmaterial.
|
||||||
|
*/
|
||||||
|
const KEIN_CACHE = { "Cache-Control": "no-store, max-age=0", Pragma: "no-cache" } as const;
|
||||||
|
|
||||||
|
function text(meldung: string, status: number): NextResponse {
|
||||||
|
return new NextResponse(`${meldung}\n`, {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "text/plain; charset=utf-8", ...KEIN_CACHE },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kommt die Anfrage von dieser Anwendung? Wegen `Referrer-Policy: no-referrer` schicken
|
||||||
|
* Browser bei Formular-POSTs `Origin: null`; massgeblich ist deshalb `Sec-Fetch-Site`, das
|
||||||
|
* Seiten nicht faelschen koennen. Ein passender Origin genuegt ebenso.
|
||||||
|
*/
|
||||||
|
function vonHier(anfrage: NextRequest): boolean {
|
||||||
|
if (anfrage.headers.get("sec-fetch-site") === "same-origin") return true;
|
||||||
|
return anfrage.headers.get("origin") === oeffentlicheAdresse("/", anfrage.nextUrl.origin).origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(anfrage: NextRequest, { params }: { params: Promise<{ vorgangId: string }> }) {
|
||||||
|
if (!vonHier(anfrage)) return text("Anfrage von fremder Herkunft.", 403);
|
||||||
|
|
||||||
|
let benutzer;
|
||||||
|
try {
|
||||||
|
({ benutzer } = await rechtErforderlich("privateKeyAnzeigen"));
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler || fehler instanceof BestaetigungFehler)
|
||||||
|
return text(fehler.message, 403);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { vorgangId } = await params;
|
||||||
|
try {
|
||||||
|
const abholung = await privateKeyAbholen(vorgangId.slice(0, 64), "herunterladen", akteurVon(benutzer));
|
||||||
|
return new NextResponse(abholung.privateKey, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
// Der Slug passt per DB-CHECK in einen Dateinamen; so heisst die Datei wie in ~/.ssh.
|
||||||
|
"Content-Disposition": `attachment; filename="${abholung.slug}"`,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
...KEIN_CACHE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof HostFehler) return text(fehler.message, 410);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { publicKeyExport } from "@/server/queries/schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Key als Datei `<slug>.pub` (#012). Ein Public Key ist kein Geheimnis – jede
|
||||||
|
* angemeldete Rolle darf ihn holen. Der Slug passt per DB-CHECK in einen Dateinamen.
|
||||||
|
*/
|
||||||
|
export async function GET(_anfrage: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
await rechtErforderlich("lesen");
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const schluessel = await publicKeyExport((await params).id);
|
||||||
|
if (!schluessel) return NextResponse.json({ fehler: "Unbekannter Schlüssel." }, { status: 404 });
|
||||||
|
return new NextResponse(`${schluessel.publicKey}\n`, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${schluessel.slug}.pub"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* BEISPIELDATEN für den Prototyp des Schlüsseldetails (#002) – kein echter Schlüssel.
|
|
||||||
* Ersetzt durch das Lesemodell aus #012/#016/#017.
|
|
||||||
*/
|
|
||||||
import type { HostStatus } from "@/components/ui/Statuspunkt";
|
|
||||||
import type { DiffZeile } from "@/components/ui/DiffBox";
|
|
||||||
|
|
||||||
export const beispielSchluessel = {
|
|
||||||
zweck: "Backup srv-backup01 → srv-pve01",
|
|
||||||
typ: "ed25519",
|
|
||||||
kategorie: "Dienst",
|
|
||||||
vorlage: "Backup-Zugang",
|
|
||||||
ablauf: "2026-10-30T10:00:00Z",
|
|
||||||
fingerprint: "SHA256:q3vN8xP0b4Tz7KfL2mWcR9yHdE1sJ6uAoGi5VnBQe0k",
|
|
||||||
erstellt: "2026-04-30T08:12:00Z",
|
|
||||||
erstelltVon: "Florian Wilde",
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Ausrollung = {
|
|
||||||
ziel: string;
|
|
||||||
pfad: string;
|
|
||||||
zusatz?: string;
|
|
||||||
status: HostStatus;
|
|
||||||
statusText?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const privatAusgerollt: Ausrollung[] = [
|
|
||||||
{
|
|
||||||
ziel: "backup@srv-backup01",
|
|
||||||
pfad: "~/.ssh/id_ed25519_pve01",
|
|
||||||
zusatz: "config: Host srv-pve01",
|
|
||||||
status: "synchron",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const publicAusgerollt: Ausrollung[] = [
|
|
||||||
{
|
|
||||||
ziel: "backup@srv-pve01",
|
|
||||||
pfad: "~/.ssh/authorized_keys",
|
|
||||||
zusatz: 'from="10.20.0.15",no-pty,no-agent-forwarding',
|
|
||||||
status: "synchron",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ziel: "backup@srv-pve02",
|
|
||||||
pfad: "~/.ssh/authorized_keys",
|
|
||||||
zusatz: 'from="10.20.0.15",no-pty',
|
|
||||||
status: "drift",
|
|
||||||
statusText: "Optionen geändert",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const dryRunDatei = "backup@srv-pve02:~/.ssh/authorized_keys";
|
|
||||||
|
|
||||||
export const dryRunDiff: DiffZeile[] = [
|
|
||||||
{ art: "kontext", text: "# --- sshkv verwaltet: Anfang ---" },
|
|
||||||
{
|
|
||||||
art: "kontext",
|
|
||||||
text: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMvq2Lr7… monitoring@zabbix",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
art: "entfernt",
|
|
||||||
text: 'from="10.20.0.15",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
art: "hinzu",
|
|
||||||
text: 'from="10.20.0.15",no-pty,no-agent-forwarding ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFt0b8c1… backup@srv-backup01',
|
|
||||||
},
|
|
||||||
{ art: "kontext", text: "# --- sshkv verwaltet: Ende ---" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const zusatzaktionen = [
|
|
||||||
{ id: "backup", text: "Backup der Datei anlegen (authorized_keys.sshkv-bak)", an: true },
|
|
||||||
{ id: "config", text: "~/.ssh/config-Eintrag auf srv-backup01 anlegen", an: true },
|
|
||||||
{ id: "knownhosts", text: "known_hosts auf srv-backup01 aktualisieren", an: false },
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „+ Ausrollen“: Knopf mit nativem Dialog (#016, #017). Offen/zu als Zustand, der Effekt
|
||||||
|
* gleicht den Dialog an – so liest kein Render-Pfad die Ref.
|
||||||
|
*/
|
||||||
|
export function AusrollDialog({
|
||||||
|
titel,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
titel: string;
|
||||||
|
children: (schliessen: () => void) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
|
||||||
|
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 klein icon={<Plus className="size-4" />} onClick={() => setOffen(true)}>
|
||||||
|
Ausrollen
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-label={titel}
|
||||||
|
onClose={schliessen}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<h2 className="font-display text-[18px] font-semibold">{titel}</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 items-center justify-center"
|
||||||
|
>
|
||||||
|
<X className="size-5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{offen && children(schliessen)}
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Play, ScanSearch } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { DiffBox } from "@/components/ui/DiffBox";
|
||||||
|
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
|
||||||
|
import { ausrollenAktion, trockenlaufAktion, type AusrollAuswahl } from "@/server/actions/verwendungen";
|
||||||
|
import type { DiffZeile } from "@/lib/ssh/zeilen-diff";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dry-Run-Vorschau und Ausfuehren (#016). Was angezeigt wird, ist genau das, was der Worker
|
||||||
|
* schreibt: der Plan traegt die Hashes, und das Ausrollen bricht ab, wenn sich seitdem auf
|
||||||
|
* dem Host oder an den Zuweisungen etwas geaendert hat.
|
||||||
|
*/
|
||||||
|
type DateiPlan = {
|
||||||
|
art: "privateKey" | "publicKey" | "config" | "knownHosts";
|
||||||
|
pfad: string;
|
||||||
|
aktion: "anlegen" | "aendern" | "keine" | "entfernen";
|
||||||
|
diff?: DiffZeile[];
|
||||||
|
hinweis?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlanZiel = {
|
||||||
|
art?: "public" | "privat";
|
||||||
|
dateien?: DateiPlan[];
|
||||||
|
hostName: string;
|
||||||
|
zielbenutzer: string;
|
||||||
|
pfad?: string;
|
||||||
|
geaendert: boolean;
|
||||||
|
diff: DiffZeile[];
|
||||||
|
hinweise: string[];
|
||||||
|
fehler?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; offen: number }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [trockenlaufId, setTrockenlaufId] = useState<string>();
|
||||||
|
const [ausrollenId, setAusrollenId] = useState<string>();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [startet, starten] = useTransition();
|
||||||
|
const trockenlauf = useVorgang(trockenlaufId);
|
||||||
|
const ausrollen = useVorgang(ausrollenId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ausrollen.stand && !laeuftNoch(ausrollen.stand)) router.refresh();
|
||||||
|
}, [ausrollen.stand, router]);
|
||||||
|
|
||||||
|
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
|
||||||
|
const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0;
|
||||||
|
const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand);
|
||||||
|
const fertig = ausrollen.stand?.status === "ERLEDIGT";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
icon={<ScanSearch className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
setAusrollenId(undefined);
|
||||||
|
const antwort = await trockenlaufAktion(auswahl);
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else setTrockenlaufId(antwort.vorgangId);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laeuftNoch(trockenlauf.stand) ? "Trockenlauf läuft …" : "Dry-Run starten"}
|
||||||
|
</Button>
|
||||||
|
{offen > 0 && !plan && (
|
||||||
|
<span className="text-accent text-[13.5px]">{offen} Zuweisung(en) noch nicht ausgerollt.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(fehler || trockenlauf.fehler || trockenlauf.stand?.status === "FEHLGESCHLAGEN") && (
|
||||||
|
<p role="status" className="text-danger text-[14px]">
|
||||||
|
{fehler ?? trockenlauf.fehler ?? trockenlauf.stand?.fehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{plan?.map((ziel) => (
|
||||||
|
<div key={`${ziel.hostName}-${ziel.zielbenutzer}`} className="flex flex-col gap-2">
|
||||||
|
{ziel.fehler ? (
|
||||||
|
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-4 py-3 font-mono text-[13px]">
|
||||||
|
{ziel.zielbenutzer}@{ziel.hostName}: {ziel.fehler}
|
||||||
|
</p>
|
||||||
|
) : ziel.art === "privat" ? (
|
||||||
|
<PrivatPlanAnzeige ziel={ziel} />
|
||||||
|
) : (
|
||||||
|
<DiffBox
|
||||||
|
datei={`${ziel.zielbenutzer}@${ziel.hostName}:${ziel.pfad ?? "~/.ssh/authorized_keys"}`}
|
||||||
|
zeilen={ziel.geaendert ? ziel.diff : [{ art: "kontext", text: "Keine Änderung nötig." }]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ziel.hinweise.map((h) => (
|
||||||
|
<p key={h} className="text-accent text-[13px]">
|
||||||
|
▲ {h}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{plan && aenderungen > 0 && !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" />
|
||||||
|
Backup jeder geänderten Datei anlegen (
|
||||||
|
<code className="font-mono text-[12.5px]">….sshkv.bak.…</code>) – außer bei Private Keys, von
|
||||||
|
denen keine zweite Kopie liegen bleiben soll
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
variante="primaer"
|
||||||
|
icon={<Play className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
const antwort = await ausrollenAktion(trockenlaufId!);
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else setAusrollenId(antwort.vorgangId);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laeuftNoch(ausrollen.stand) ? "Wird ausgerollt …" : `Änderung anwenden (${aenderungen})`}
|
||||||
|
</Button>
|
||||||
|
<Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
|
||||||
|
Verwerfen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{plan && aenderungen === 0 && (
|
||||||
|
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
|
||||||
|
)}
|
||||||
|
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && (
|
||||||
|
<p role="status" className="text-danger text-[14px]">
|
||||||
|
{ausrollen.stand.fehler}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{fertig && <p className="text-ok text-[14px]">Ausgerollt.</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AKTION_TEXT: Record<DateiPlan["aktion"], string> = {
|
||||||
|
anlegen: "wird angelegt",
|
||||||
|
aendern: "wird geändert",
|
||||||
|
entfernen: "wird entfernt",
|
||||||
|
keine: "unverändert",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private-Key-Ziel (#017): die Dateien unter ~/.ssh einzeln. Vom Private Key steht nur die
|
||||||
|
* Aktion da – sein Inhalt erreicht den Browser nie.
|
||||||
|
*/
|
||||||
|
function PrivatPlanAnzeige({ ziel }: { ziel: PlanZiel }) {
|
||||||
|
const dateien = ziel.dateien ?? [];
|
||||||
|
return (
|
||||||
|
<div className="border-border bg-bg rounded-box flex flex-col gap-3 border p-4">
|
||||||
|
<p className="text-muted font-mono text-[12.5px] break-all">
|
||||||
|
{ziel.zielbenutzer}@{ziel.hostName} · Private Key, ~/.ssh/config, known_hosts
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-1 font-mono text-[12.5px]">
|
||||||
|
{dateien
|
||||||
|
.filter((d) => d.art === "privateKey" || d.art === "publicKey")
|
||||||
|
.map((d) => (
|
||||||
|
<li
|
||||||
|
key={d.pfad}
|
||||||
|
className={
|
||||||
|
d.aktion === "keine" ? "text-muted" : d.aktion === "entfernen" ? "text-danger" : "text-ok"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{d.aktion === "keine" ? " " : d.aktion === "entfernen" ? "−" : "+"} {d.pfad} –{" "}
|
||||||
|
{AKTION_TEXT[d.aktion]}
|
||||||
|
{d.art === "privateKey" && d.aktion !== "keine" && d.aktion !== "entfernen"
|
||||||
|
? " (Inhalt wird nicht angezeigt)"
|
||||||
|
: ""}
|
||||||
|
{d.hinweis ? ` · ${d.hinweis}` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{dateien
|
||||||
|
.filter((d) => (d.art === "config" || d.art === "knownHosts") && d.aktion !== "keine" && d.diff)
|
||||||
|
.map((d) => (
|
||||||
|
<DiffBox key={d.pfad} datei={`${ziel.zielbenutzer}@${ziel.hostName}:${d.pfad}`} zeilen={d.diff!} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||||
|
import {
|
||||||
|
privateEntfernenAktion,
|
||||||
|
publicEntfernenAktion,
|
||||||
|
verbindungTestenAktion,
|
||||||
|
} from "@/server/actions/verwendungen";
|
||||||
|
|
||||||
|
/** Je Eintrag: Entfernen vormerken, bei Private Keys mit Alias den Verbindungstest (#016/#017/#020). */
|
||||||
|
export function EintragAktionen({
|
||||||
|
art,
|
||||||
|
verwendungId,
|
||||||
|
schluesselId,
|
||||||
|
darfEntfernen,
|
||||||
|
testbar,
|
||||||
|
}: {
|
||||||
|
art: "public" | "privat";
|
||||||
|
verwendungId: string;
|
||||||
|
schluesselId: string;
|
||||||
|
darfEntfernen: boolean;
|
||||||
|
testbar: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-start gap-2">
|
||||||
|
{testbar && (
|
||||||
|
<VorgangsKnopf
|
||||||
|
klein
|
||||||
|
aktion={verbindungTestenAktion.bind(null, verwendungId)}
|
||||||
|
beschriftung="Verbindung testen"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{darfEntfernen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={laeuft}
|
||||||
|
title="Zuweisung entfernen"
|
||||||
|
aria-label="Zuweisung entfernen"
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
const antwort = await (art === "public" ? publicEntfernenAktion : privateEntfernenAktion)(
|
||||||
|
verwendungId,
|
||||||
|
schluesselId,
|
||||||
|
);
|
||||||
|
if (antwort.fehler) setFehler(antwort.fehler);
|
||||||
|
else router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-muted hover:text-danger hover:bg-danger-soft rounded-feld flex size-11 items-center justify-center"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" aria-hidden />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{fehler && <p className="text-danger text-[13px]">{fehler}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Ankreuzfeld, Button, Feld, Formularfehler } from "@/components/ui";
|
||||||
|
import { Auswahlliste } from "@/components/ui/Feld";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { privateZuweisenAktion } from "@/server/actions/verwendungen";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private Key auf einen Quell-Host legen (#017), optional mit ~/.ssh/config-Eintrag und dem
|
||||||
|
* Host-Key des Ziels in known_hosts – das, was ein Cronjob auf dem Quell-Host braucht.
|
||||||
|
*/
|
||||||
|
export function PrivateZuweisenFormular({
|
||||||
|
schluesselId,
|
||||||
|
slug,
|
||||||
|
auswahl,
|
||||||
|
passphraseGeschuetzt,
|
||||||
|
beiErfolg,
|
||||||
|
}: {
|
||||||
|
schluesselId: string;
|
||||||
|
slug: string;
|
||||||
|
auswahl: ZielAuswahl & { hostsDetails: { id: string; name: string; adresse: string; port: number }[] };
|
||||||
|
passphraseGeschuetzt: boolean;
|
||||||
|
beiErfolg?: () => void;
|
||||||
|
}) {
|
||||||
|
const [mitConfig, setMitConfig] = useState(true);
|
||||||
|
const [zielHostId, setZielHostId] = useState("");
|
||||||
|
const ziel = auswahl.hostsDetails.find((h) => h.id === zielHostId);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||||
|
const ergebnis = await privateZuweisenAktion(vorher, formular);
|
||||||
|
if (!ergebnis.fehler && !ergebnis.feldfehler) beiErfolg?.();
|
||||||
|
return ergebnis;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="schluesselId" value={schluesselId} />
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
{passphraseGeschuetzt && (
|
||||||
|
<p className="border-border-strong text-accent rounded-box border px-3 py-2.5 text-[13.5px]">
|
||||||
|
▲ Dieser Private Key ist passphrasengeschützt – für automatische Anmeldungen (Host-zu-Host)
|
||||||
|
ungeeignet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Quell-Host (bekommt den Private Key)"
|
||||||
|
name="hostId"
|
||||||
|
defaultValue=""
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Host wählen …
|
||||||
|
</option>
|
||||||
|
{auswahl.hosts.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
{h.bereit ? "" : " (noch kein Bootstrap)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zielbenutzer"
|
||||||
|
name="zielbenutzer"
|
||||||
|
defaultValue="root"
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Pfad"
|
||||||
|
name="pfad"
|
||||||
|
defaultValue={`~/.ssh/id_ed25519_${slug}`}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Ankreuzfeld
|
||||||
|
beschriftung="Eintrag in ~/.ssh/config anlegen"
|
||||||
|
erklaerung="Dann genügt auf dem Quell-Host ein einfaches „ssh <Alias>“."
|
||||||
|
checked={mitConfig}
|
||||||
|
onChange={(e) => setMitConfig(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{mitConfig && (
|
||||||
|
<div className="border-border flex flex-col gap-4 border-l-2 pl-4">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Ziel-Host (für known_hosts)"
|
||||||
|
name="zielHostId"
|
||||||
|
value={zielHostId}
|
||||||
|
onChange={(e) => setZielHostId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">– kein verwalteter Host –</option>
|
||||||
|
{auswahl.hostsDetails.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
key={`alias-${zielHostId}`}
|
||||||
|
beschriftung="Alias"
|
||||||
|
name="alias"
|
||||||
|
defaultValue={ziel ? ziel.name : ""}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
key={`hn-${zielHostId}`}
|
||||||
|
beschriftung="HostName"
|
||||||
|
name="hostName"
|
||||||
|
defaultValue={ziel?.adresse ?? ""}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
hinweis="So, wie der Quell-Host das Ziel erreicht."
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Benutzer am Ziel"
|
||||||
|
name="sshBenutzer"
|
||||||
|
defaultValue="root"
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
key={`port-${zielHostId}`}
|
||||||
|
beschriftung="Port"
|
||||||
|
name="port"
|
||||||
|
type="number"
|
||||||
|
defaultValue={ziel?.port ?? 22}
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Auswahlliste beschriftung="Beim Widerruf" name="beimWiderruf" defaultValue="LOESCHEN">
|
||||||
|
<option value="LOESCHEN">Datei löschen</option>
|
||||||
|
<option value="UMBENENNEN">Datei umbenennen (…sshkv-widerrufen…)</option>
|
||||||
|
</Auswahlliste>
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : "Zuweisen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Feld, Formularfehler } from "@/components/ui";
|
||||||
|
import { Auswahlliste } from "@/components/ui/Feld";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { publicZuweisenAktion } from "@/server/actions/verwendungen";
|
||||||
|
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Key einem Host oder einer Hostgruppe zuweisen (#016). Die Zuweisung allein aendert
|
||||||
|
* noch nichts auf dem Host – erst Trockenlauf und Ausrollen.
|
||||||
|
*/
|
||||||
|
export function PublicZuweisenFormular({
|
||||||
|
schluesselId,
|
||||||
|
auswahl,
|
||||||
|
beiErfolg,
|
||||||
|
}: {
|
||||||
|
schluesselId: string;
|
||||||
|
auswahl: ZielAuswahl;
|
||||||
|
beiErfolg?: () => void;
|
||||||
|
}) {
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||||
|
const ergebnis = await publicZuweisenAktion(vorher, formular);
|
||||||
|
if (!ergebnis.fehler && !ergebnis.feldfehler) beiErfolg?.();
|
||||||
|
return ergebnis;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<option value="" disabled>
|
||||||
|
Host oder Hostgruppe wählen …
|
||||||
|
</option>
|
||||||
|
<optgroup label="Hosts">
|
||||||
|
{auswahl.hosts.map((h) => (
|
||||||
|
<option key={h.id} value={`host:${h.id}`}>
|
||||||
|
{h.name}
|
||||||
|
{h.bereit ? "" : " (noch kein Bootstrap)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
{auswahl.gruppen.length > 0 && (
|
||||||
|
<optgroup label="Hostgruppen">
|
||||||
|
{auswahl.gruppen.map((g) => (
|
||||||
|
<option key={g.id} value={`gruppe:${g.id}`}>
|
||||||
|
{g.name} ({g.mitglieder} Hosts)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</Auswahlliste>
|
||||||
|
{zustand.feldfehler?.["ziel"] && (
|
||||||
|
<p className="text-danger -mt-2 text-[13px]">{zustand.feldfehler["ziel"]}</p>
|
||||||
|
)}
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zielbenutzer"
|
||||||
|
name="zielbenutzer"
|
||||||
|
defaultValue="root"
|
||||||
|
mono
|
||||||
|
required
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Einschränkungen in authorized_keys (optional)"
|
||||||
|
name="optionen"
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder='restrict,from="10.0.0.12"'
|
||||||
|
hinweis='z. B. restrict, from="IP", command="…", no-pty, no-port-forwarding'
|
||||||
|
/>
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : "Zuweisen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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 }> = {
|
||||||
|
GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" },
|
||||||
|
AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" },
|
||||||
|
ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" },
|
||||||
|
ENTFERNEN: { text: "wird entfernt", klasse: "text-accent", punkt: "○" },
|
||||||
|
ENTFERNT: { text: "entfernt", klasse: "text-muted", punkt: "○" },
|
||||||
|
FEHLGESCHLAGEN: { text: "fehlgeschlagen", klasse: "text-danger", punkt: "●" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) {
|
||||||
|
const anzeige = ANZEIGE[zustand];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"inline-flex items-center gap-1.5 text-[13px] whitespace-nowrap",
|
||||||
|
anzeige.klasse,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{anzeige.punkt}</span>
|
||||||
|
{anzeige.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
KeySquare,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutTemplate,
|
LayoutTemplate,
|
||||||
Network,
|
Network,
|
||||||
@@ -20,6 +21,8 @@ export const HAUPTNAVIGATION: NavPunkt[] = [
|
|||||||
{ href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes },
|
{ href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes },
|
||||||
{ href: "/schluessel", titel: "Schlüssel", icon: KeyRound },
|
{ href: "/schluessel", titel: "Schlüssel", icon: KeyRound },
|
||||||
{ href: "/personen", titel: "Personen", icon: Users },
|
{ href: "/personen", titel: "Personen", icon: Users },
|
||||||
|
// Self-Service fuer jede Rolle (#013): eigene Public Keys hinterlegen.
|
||||||
|
{ href: "/meine-schluessel", titel: "Meine Schlüssel", icon: KeySquare },
|
||||||
{ href: "/zuweisungen", titel: "Zuweisungen", icon: Network },
|
{ href: "/zuweisungen", titel: "Zuweisungen", icon: Network },
|
||||||
{ href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate },
|
{ href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useActionState, useEffect, useRef } from "react";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui";
|
||||||
|
import type { FormularZustand } from "@/server/actions/formular";
|
||||||
|
import {
|
||||||
|
eigenenKeyEntfernenAktion,
|
||||||
|
eigenenKeyHinterlegenAktion,
|
||||||
|
type HinterlegenZustand,
|
||||||
|
} from "@/server/actions/meine-schluessel";
|
||||||
|
|
||||||
|
/** Eigenen Public Key hinterlegen (#013). Nach Erfolg leert sich das Formular. */
|
||||||
|
export function KeyHinterlegen() {
|
||||||
|
const formular = useRef<HTMLFormElement>(null);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(
|
||||||
|
eigenenKeyHinterlegenAktion as (v: FormularZustand, f: FormData) => Promise<HinterlegenZustand>,
|
||||||
|
);
|
||||||
|
const erfolg = (zustand as HinterlegenZustand).erfolg;
|
||||||
|
useEffect(() => {
|
||||||
|
if (erfolg) formular.current?.reset();
|
||||||
|
}, [erfolg]);
|
||||||
|
const f = zustand.feldfehler ?? {};
|
||||||
|
return (
|
||||||
|
<Karte
|
||||||
|
titel="Public Key hinterlegen"
|
||||||
|
untertitel="Eine Zeile im OpenSSH-Format, z. B. aus ~/.ssh/id_ed25519.pub"
|
||||||
|
>
|
||||||
|
<form ref={formular} onSubmit={absenden} noValidate className="flex flex-col gap-4">
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
{erfolg && (
|
||||||
|
<p role="status" className="text-ok text-[14px] break-all">
|
||||||
|
{erfolg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Feld
|
||||||
|
beschriftung="Bezeichnung"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Laptop, Workstation …"
|
||||||
|
autoComplete="off"
|
||||||
|
fehler={f["name"]}
|
||||||
|
/>
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Public Key"
|
||||||
|
name="publicKey"
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… name@rechner"
|
||||||
|
fehler={f["publicKey"]}
|
||||||
|
className="[&_textarea]:font-mono [&_textarea]:text-[13px]"
|
||||||
|
/>
|
||||||
|
<p className="text-muted text-[13.5px]">
|
||||||
|
Wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist. Den Private Key
|
||||||
|
behalten Sie bei sich – sshkv fragt nie danach.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variante="primaer"
|
||||||
|
disabled={laeuft}
|
||||||
|
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||||
|
>
|
||||||
|
{laeuft ? "Speichere …" : "Hinterlegen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Karte>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenen, noch nirgends zugewiesenen Key entfernen – mit Rueckfrage. */
|
||||||
|
export function KeyEntfernen({ schluesselId, name }: { schluesselId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(eigenenKeyEntfernenAktion, {});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
klein
|
||||||
|
icon={<Trash2 className="size-4" />}
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
aria-label={`${name} entfernen`}
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-labelledby={`entfernen-${schluesselId}`}
|
||||||
|
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={schluesselId} />
|
||||||
|
<h2 id={`entfernen-${schluesselId}`} className="text-[17px] font-semibold">
|
||||||
|
„{name}“ entfernen?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
Der Public Key verschwindet aus sshkv. Er ist noch keinem Host zugewiesen; auf keinem Server
|
||||||
|
ändert sich etwas.
|
||||||
|
</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 ? "Entferne …" : "Entfernen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Archive } from "lucide-react";
|
||||||
|
import { useActionState, useRef } from "react";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { Auswahlliste, Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui";
|
||||||
|
import type { FormularZustand } from "@/server/actions/formular";
|
||||||
|
import {
|
||||||
|
personAnlegenAktion,
|
||||||
|
personArchivierenAktion,
|
||||||
|
personBearbeitenAktion,
|
||||||
|
} from "@/server/actions/personen";
|
||||||
|
|
||||||
|
export type PersonFormularVorgabe = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
organisation: string;
|
||||||
|
notiz: string;
|
||||||
|
benutzerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LEERE_PERSON: PersonFormularVorgabe = {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
organisation: "",
|
||||||
|
notiz: "",
|
||||||
|
benutzerId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Formular fuer neue und bestehende Personen (#013). Regeln prueft der Server. */
|
||||||
|
export function PersonFormular({
|
||||||
|
vorgabe,
|
||||||
|
konten,
|
||||||
|
abbrechenZiel,
|
||||||
|
}: {
|
||||||
|
vorgabe: PersonFormularVorgabe;
|
||||||
|
konten: { id: string; name: string; email: string | null }[];
|
||||||
|
abbrechenZiel: string;
|
||||||
|
}) {
|
||||||
|
const bearbeiten = Boolean(vorgabe.id);
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(
|
||||||
|
bearbeiten ? personBearbeitenAktion : personAnlegenAktion,
|
||||||
|
);
|
||||||
|
const f = zustand.feldfehler ?? {};
|
||||||
|
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="Person">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Name"
|
||||||
|
name="name"
|
||||||
|
defaultValue={vorgabe.name}
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
autoComplete="off"
|
||||||
|
fehler={f["name"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="E-Mail (optional)"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
defaultValue={vorgabe.email}
|
||||||
|
maxLength={254}
|
||||||
|
autoComplete="off"
|
||||||
|
fehler={f["email"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Organisation (optional)"
|
||||||
|
name="organisation"
|
||||||
|
defaultValue={vorgabe.organisation}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="z. B. Dienstleister"
|
||||||
|
fehler={f["organisation"]}
|
||||||
|
/>
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Verknüpftes Konto (optional)"
|
||||||
|
name="benutzerId"
|
||||||
|
defaultValue={vorgabe.benutzerId}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
>
|
||||||
|
<option value="">keines</option>
|
||||||
|
{konten.map((k) => (
|
||||||
|
<option key={k.id} value={k.id}>
|
||||||
|
{k.email ? `${k.name} (${k.email})` : k.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
{f["benutzerId"] && <p className="text-danger text-[13px] sm:col-span-2">{f["benutzerId"]}</p>}
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Notiz (optional)"
|
||||||
|
name="notiz"
|
||||||
|
defaultValue={vorgabe.notiz}
|
||||||
|
maxLength={1000}
|
||||||
|
fehler={f["notiz"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Person anlegen"}
|
||||||
|
</Button>
|
||||||
|
<Button href={abbrechenZiel}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archivieren mit Rueckfrage, nativer `<dialog>` wie bei Hosts (#008). */
|
||||||
|
export function PersonArchivieren({ personId, name }: { personId: string; name: string }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(personArchivierenAktion, {});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Archive className="size-4" />}
|
||||||
|
onClick={() => dialog.current?.showModal()}
|
||||||
|
>
|
||||||
|
Person archivieren
|
||||||
|
</Button>
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
aria-labelledby="person-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={personId} />
|
||||||
|
<h2 id="person-archivieren-titel" className="text-[17px] font-semibold">
|
||||||
|
{name} archivieren?
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted text-[14.5px]">
|
||||||
|
Die Person verschwindet aus Listen und Auswahlfeldern, die Verknüpfung zum Konto entfällt. Ihre
|
||||||
|
Schlüssel bleiben zugeordnet und unverändert – widerrufen ist ein eigener Schritt.
|
||||||
|
</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,66 @@
|
|||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { datum } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
ablaufZustand,
|
||||||
|
KATEGORIE_TEXT,
|
||||||
|
STATUS_TEXT,
|
||||||
|
typAnzeige,
|
||||||
|
type Kategorie,
|
||||||
|
type Status,
|
||||||
|
} from "@/lib/schluessel";
|
||||||
|
|
||||||
|
/** Kleine Anzeigebausteine fuer Schluessel (#012) – reine Darstellung. */
|
||||||
|
|
||||||
|
export function TypBadge({ typ, publicKey }: { typ: "ED25519" | "RSA4096" | "ANDERER"; publicKey: string }) {
|
||||||
|
return <Badge mono>{typAnzeige(typ, publicKey)}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KategorieBadge({ kategorie }: { kategorie: Kategorie }) {
|
||||||
|
return <Badge>{KATEGORIE_TEXT[kategorie]}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AKTIV zeigen wir nicht eigens – nur Abweichungen vom Normalfall. */
|
||||||
|
export function StatusBadge({ status }: { status: Status }) {
|
||||||
|
if (status === "AKTIV") return null;
|
||||||
|
return <Badge variante={status === "ROTIERT" ? "neutral" : "gefahr"}>{STATUS_TEXT[status]}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ablauf: in Messing, sobald er in hoechstens 30 Tagen ansteht (Design #002), in Koralle,
|
||||||
|
* wenn er vorbei ist. `lang` zeigt zusaetzlich das Datum (Detailseite).
|
||||||
|
*/
|
||||||
|
export function AblaufBadge({
|
||||||
|
ablaufAm,
|
||||||
|
jetzt,
|
||||||
|
lang = false,
|
||||||
|
}: {
|
||||||
|
ablaufAm: Date | null;
|
||||||
|
jetzt: Date;
|
||||||
|
lang?: boolean;
|
||||||
|
}) {
|
||||||
|
const zustand = ablaufZustand(ablaufAm, jetzt);
|
||||||
|
if (zustand.art === "keiner" || !ablaufAm) return null;
|
||||||
|
const zeit = <time dateTime={ablaufAm.toISOString()}>{datum(ablaufAm)}</time>;
|
||||||
|
if (zustand.art === "abgelaufen") {
|
||||||
|
return <Badge variante="gefahr">{lang ? <span>abgelaufen am {zeit}</span> : "abgelaufen"}</Badge>;
|
||||||
|
}
|
||||||
|
const in_ = zustand.tage === 0 ? "heute" : zustand.tage === 1 ? "morgen" : `in ${zustand.tage} Tagen`;
|
||||||
|
if (zustand.art === "bald") {
|
||||||
|
return (
|
||||||
|
<Badge variante="messing">
|
||||||
|
{lang ? (
|
||||||
|
<span>
|
||||||
|
läuft ab am {zeit} · {in_}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>läuft ab {in_}</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lang ? (
|
||||||
|
<Badge variante="neutral" className="text-muted">
|
||||||
|
<span>läuft ab am {zeit}</span>
|
||||||
|
</Badge>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Auswahlliste, Button, Feld, Formularfehler, Karte } from "@/components/ui";
|
||||||
|
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||||
|
import { schluesselBearbeitenAktion } from "@/server/actions/schluessel";
|
||||||
|
import type { Auswahl } from "./SchluesselNeu";
|
||||||
|
|
||||||
|
export type SchluesselFormularWerte = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tags: string[];
|
||||||
|
/** JJJJ-MM-TT oder leer */
|
||||||
|
ablaufAm: string;
|
||||||
|
personId: string;
|
||||||
|
vorlageId: string;
|
||||||
|
status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
|
||||||
|
statusAenderbar: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadaten bearbeiten (#012). Slug, Typ und Kommentar bleiben – sie stehen schon in
|
||||||
|
* Dateinamen und authorized_keys-Zeilen auf den Hosts.
|
||||||
|
*/
|
||||||
|
export function SchluesselBearbeiten({
|
||||||
|
werte,
|
||||||
|
personen,
|
||||||
|
vorlagen,
|
||||||
|
}: {
|
||||||
|
werte: SchluesselFormularWerte;
|
||||||
|
personen: Auswahl[];
|
||||||
|
vorlagen: Auswahl[];
|
||||||
|
}) {
|
||||||
|
const { zustand, absenden, laeuft } = useFormularAktion(schluesselBearbeitenAktion);
|
||||||
|
const f = zustand.feldfehler ?? {};
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||||
|
<input type="hidden" name="id" value={werte.id} />
|
||||||
|
<Formularfehler text={zustand.fehler} />
|
||||||
|
<Karte titel="Metadaten">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zweck"
|
||||||
|
name="name"
|
||||||
|
defaultValue={werte.name}
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
fehler={f["name"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<Auswahlliste beschriftung="Person" name="personId" defaultValue={werte.personId}>
|
||||||
|
<option value="">keine</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Vorlage" name="vorlageId" defaultValue={werte.vorlageId}>
|
||||||
|
<option value="">keine</option>
|
||||||
|
{vorlagen.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Ablaufdatum"
|
||||||
|
name="ablaufAm"
|
||||||
|
type="date"
|
||||||
|
defaultValue={werte.ablaufAm}
|
||||||
|
fehler={f["ablaufAm"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Tags"
|
||||||
|
name="tags"
|
||||||
|
mono
|
||||||
|
defaultValue={werte.tags.join(", ")}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
|
||||||
|
fehler={f["tags"]}
|
||||||
|
/>
|
||||||
|
{werte.statusAenderbar && (
|
||||||
|
<Auswahlliste beschriftung="Status" name="status" defaultValue={werte.status}>
|
||||||
|
<option value="AKTIV">aktiv</option>
|
||||||
|
<option value="ABGELAUFEN">abgelaufen</option>
|
||||||
|
</Auswahlliste>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
<div className="flex flex-wrap gap-2.5">
|
||||||
|
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||||
|
{laeuft ? "Speichere …" : "Änderungen speichern"}
|
||||||
|
</Button>
|
||||||
|
<Button href={`/schluessel/${werte.id}`}>Abbrechen</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Download, KeyRound, Sparkles } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, useTransition, type FormEvent, type ReactNode } from "react";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import {
|
||||||
|
Ankreuzfeld,
|
||||||
|
Auswahlliste,
|
||||||
|
Button,
|
||||||
|
Feld,
|
||||||
|
Feldgruppe,
|
||||||
|
Formularfehler,
|
||||||
|
Karte,
|
||||||
|
Textbereich,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
|
||||||
|
import {
|
||||||
|
paarImportierenAktion,
|
||||||
|
publicKeyImportierenAktion,
|
||||||
|
schluesselErzeugenAktion,
|
||||||
|
type SchluesselAntwort,
|
||||||
|
} from "@/server/actions/schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuer Schluessel (#012): erzeugen oder importieren (nur Public Key oder Paar).
|
||||||
|
*
|
||||||
|
* Erzeugen und Paar-Import laufen als Vorgang im Worker; das Formular verfolgt ihn und
|
||||||
|
* springt nach Erfolg zum Schluessel. Passphrase und Private Key stehen nur so lange im
|
||||||
|
* Formular, bis sie abgeschickt sind.
|
||||||
|
*/
|
||||||
|
type Modus = "erzeugen" | "public" | "paar";
|
||||||
|
|
||||||
|
export type Auswahl = { id: string; name: string; zusatz?: string | null };
|
||||||
|
|
||||||
|
const HINWEIS_PASSPHRASE = "Für automatische Anmeldungen (Host-zu-Host) ungeeignet.";
|
||||||
|
|
||||||
|
function Umschalter({
|
||||||
|
wert,
|
||||||
|
setzen,
|
||||||
|
optionen,
|
||||||
|
beschriftung,
|
||||||
|
}: {
|
||||||
|
wert: string;
|
||||||
|
setzen: (wert: string) => void;
|
||||||
|
optionen: { wert: string; text: string; icon?: ReactNode }[];
|
||||||
|
beschriftung: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={beschriftung}
|
||||||
|
className="border-border-strong bg-sidebar rounded-box inline-flex w-full max-w-md gap-1 border p-1 sm:w-auto sm:self-start"
|
||||||
|
>
|
||||||
|
{optionen.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.wert}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={wert === o.wert}
|
||||||
|
onClick={() => setzen(o.wert)}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-feld inline-flex min-h-10 flex-1 items-center justify-center gap-2 px-4 text-[14px] font-medium transition-colors sm:flex-none",
|
||||||
|
wert === o.wert ? "bg-raised text-text" : "text-muted hover:text-text",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.icon}
|
||||||
|
{o.text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchluesselNeu({
|
||||||
|
personen,
|
||||||
|
vorlagen,
|
||||||
|
vorgabePersonId,
|
||||||
|
}: {
|
||||||
|
personen: Auswahl[];
|
||||||
|
vorlagen: Auswahl[];
|
||||||
|
vorgabePersonId?: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [art, setArt] = useState<"erzeugen" | "importieren">("erzeugen");
|
||||||
|
const [importArt, setImportArt] = useState<"public" | "paar">("public");
|
||||||
|
const modus: Modus = art === "erzeugen" ? "erzeugen" : importArt;
|
||||||
|
|
||||||
|
const [antwort, setAntwort] = useState<SchluesselAntwort>({});
|
||||||
|
const [sendet, senden] = useTransition();
|
||||||
|
const { stand, abfrageFehler } = useVorgang(antwort.vorgangId);
|
||||||
|
|
||||||
|
// Geheimnisse kontrolliert, damit sie nach dem Absenden sofort aus dem Formular verschwinden.
|
||||||
|
const [privateKey, setPrivateKey] = useState("");
|
||||||
|
const [passphrase, setPassphrase] = useState("");
|
||||||
|
const [wiederholung, setWiederholung] = useState("");
|
||||||
|
|
||||||
|
const ergebnisId =
|
||||||
|
stand?.status === "ERLEDIGT" && stand.ergebnis && typeof stand.ergebnis === "object"
|
||||||
|
? (stand.ergebnis as { schluesselId?: unknown }).schluesselId
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof ergebnisId === "string") router.push(`/schluessel/${ergebnisId}`);
|
||||||
|
}, [ergebnisId, router]);
|
||||||
|
|
||||||
|
const vorgangLaeuft = antwort.vorgangId !== undefined && istLaufend(stand) && !abfrageFehler;
|
||||||
|
const beschaeftigt = sendet || vorgangLaeuft || typeof ergebnisId === "string";
|
||||||
|
const f = antwort.feldfehler ?? {};
|
||||||
|
const fehler =
|
||||||
|
antwort.fehler ??
|
||||||
|
abfrageFehler ??
|
||||||
|
(stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined);
|
||||||
|
|
||||||
|
function absenden(ereignis: FormEvent<HTMLFormElement>) {
|
||||||
|
ereignis.preventDefault();
|
||||||
|
const daten = new FormData(ereignis.currentTarget);
|
||||||
|
const aktion =
|
||||||
|
modus === "erzeugen"
|
||||||
|
? schluesselErzeugenAktion
|
||||||
|
: modus === "paar"
|
||||||
|
? paarImportierenAktion
|
||||||
|
: publicKeyImportierenAktion;
|
||||||
|
senden(async () => {
|
||||||
|
setAntwort({});
|
||||||
|
const neu = await aktion({}, daten);
|
||||||
|
if (neu.vorgangId || neu.schluesselId) {
|
||||||
|
setPrivateKey("");
|
||||||
|
setPassphrase("");
|
||||||
|
setWiederholung("");
|
||||||
|
}
|
||||||
|
if (neu.schluesselId) router.push(`/schluessel/${neu.schluesselId}`);
|
||||||
|
setAntwort(neu);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const knopfText = beschaeftigt
|
||||||
|
? modus === "erzeugen"
|
||||||
|
? "Erzeuge …"
|
||||||
|
: "Importiere …"
|
||||||
|
: modus === "erzeugen"
|
||||||
|
? "Schlüssel erzeugen"
|
||||||
|
: "Schlüssel importieren";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={absenden} noValidate className="flex flex-col gap-6" autoComplete="off">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Umschalter
|
||||||
|
beschriftung="Art"
|
||||||
|
wert={art}
|
||||||
|
setzen={(w) => {
|
||||||
|
setArt(w as typeof art);
|
||||||
|
setAntwort({});
|
||||||
|
}}
|
||||||
|
optionen={[
|
||||||
|
{ wert: "erzeugen", text: "Erzeugen", icon: <Sparkles className="size-4" aria-hidden /> },
|
||||||
|
{ wert: "importieren", text: "Importieren", icon: <Download className="size-4" aria-hidden /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{art === "importieren" && (
|
||||||
|
<Umschalter
|
||||||
|
beschriftung="Was importieren?"
|
||||||
|
wert={importArt}
|
||||||
|
setzen={(w) => {
|
||||||
|
setImportArt(w as typeof importArt);
|
||||||
|
setAntwort({});
|
||||||
|
}}
|
||||||
|
optionen={[
|
||||||
|
{ wert: "public", text: "Nur Public Key" },
|
||||||
|
{ wert: "paar", text: "Schlüsselpaar" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Formularfehler text={fehler} />
|
||||||
|
|
||||||
|
<Karte titel="Zweck">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Zweck"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Backup srv-backup01 → srv-pve01"
|
||||||
|
fehler={f["name"]}
|
||||||
|
className="sm:col-span-2"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Kurzname (optional)"
|
||||||
|
name="slug"
|
||||||
|
mono
|
||||||
|
maxLength={64}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="wird aus dem Zweck gebildet"
|
||||||
|
hinweis="Für Dateinamen und Kommentare: a–z, 0–9, Bindestrich."
|
||||||
|
fehler={f["slug"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Kommentar (optional)"
|
||||||
|
name="kommentar"
|
||||||
|
mono
|
||||||
|
maxLength={200}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={modus === "erzeugen" ? "sshkv:<kurzname>" : "aus dem Schlüssel"}
|
||||||
|
hinweis="Steht hinter dem Public Key in authorized_keys."
|
||||||
|
fehler={f["kommentar"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
{modus === "erzeugen" && (
|
||||||
|
<Karte
|
||||||
|
titel="Schlüssel"
|
||||||
|
untertitel="sshkv erzeugt das Paar im Worker und speichert den Private Key verschlüsselt"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Feldgruppe legende="Typ" fehler={f["typ"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="typ"
|
||||||
|
value="ED25519"
|
||||||
|
defaultChecked
|
||||||
|
beschriftung={<span className="font-mono">ed25519</span>}
|
||||||
|
erklaerung="Empfohlen – kurz, schnell, sicher."
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="typ"
|
||||||
|
value="RSA4096"
|
||||||
|
beschriftung={<span className="font-mono">rsa-4096</span>}
|
||||||
|
erklaerung="Nur für alte Gegenstellen ohne ed25519."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
<p className="text-muted text-[13.5px]">
|
||||||
|
Kategorie: <span className="text-text">Dienst</span> – Personen hinterlegen ihre Public Keys
|
||||||
|
selbst unter „Meine Schlüssel“, deren Private Keys speichert sshkv nie.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Passphrase (optional)"
|
||||||
|
name="passphrase"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
hinweis={`Wird nicht gespeichert. ${HINWEIS_PASSPHRASE}`}
|
||||||
|
fehler={f["passphrase"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Passphrase wiederholen"
|
||||||
|
name="passphraseWiederholung"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={wiederholung}
|
||||||
|
onChange={(e) => setWiederholung(e.target.value)}
|
||||||
|
disabled={!passphrase}
|
||||||
|
fehler={f["passphraseWiederholung"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modus === "public" && (
|
||||||
|
<Karte titel="Public Key" untertitel="Eine Zeile im OpenSSH-Format, z. B. aus id_ed25519.pub">
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Public Key"
|
||||||
|
name="publicKey"
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… name@rechner"
|
||||||
|
fehler={f["publicKey"]}
|
||||||
|
className="[&_textarea]:font-mono [&_textarea]:text-[13px]"
|
||||||
|
/>
|
||||||
|
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="kategorie"
|
||||||
|
value="DIENST"
|
||||||
|
defaultChecked={!vorgabePersonId}
|
||||||
|
beschriftung="Dienst"
|
||||||
|
erklaerung="Ein Programm oder Host meldet sich damit an."
|
||||||
|
/>
|
||||||
|
<Ankreuzfeld
|
||||||
|
type="radio"
|
||||||
|
name="kategorie"
|
||||||
|
value="PERSON"
|
||||||
|
defaultChecked={Boolean(vorgabePersonId)}
|
||||||
|
beschriftung="Person"
|
||||||
|
erklaerung="Ein Mensch meldet sich damit an."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Feldgruppe>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modus === "paar" && (
|
||||||
|
<Karte
|
||||||
|
titel="Schlüsselpaar"
|
||||||
|
untertitel="Private Key im OpenSSH-Format – sshkv prüft ihn im Worker und speichert ihn verschlüsselt"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Textbereich
|
||||||
|
beschriftung="Private Key"
|
||||||
|
name="privateKey"
|
||||||
|
required
|
||||||
|
rows={7}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
value={privateKey}
|
||||||
|
onChange={(e) => setPrivateKey(e.target.value)}
|
||||||
|
placeholder={"-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----"}
|
||||||
|
fehler={f["privateKey"]}
|
||||||
|
className="[&_textarea]:font-mono [&_textarea]:text-[12.5px]"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Passphrase (nur falls der Key eine hat)"
|
||||||
|
name="passphrase"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={passphrase}
|
||||||
|
onChange={(e) => setPassphrase(e.target.value)}
|
||||||
|
hinweis={`Nur zur Prüfung, wird verworfen. Der Key bleibt passphrasengeschützt gespeichert – ${HINWEIS_PASSPHRASE.toLowerCase()}`}
|
||||||
|
fehler={f["passphrase"]}
|
||||||
|
className="sm:max-w-md"
|
||||||
|
/>
|
||||||
|
<p className="text-muted text-[13.5px]">
|
||||||
|
Kategorie: <span className="text-text">Dienst</span> – Private Keys von Personen speichert sshkv
|
||||||
|
nie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Karte titel="Einordnung">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung={modus === "public" ? "Person (optional)" : "Verantwortliche Person (optional)"}
|
||||||
|
name="personId"
|
||||||
|
defaultValue={vorgabePersonId ?? ""}
|
||||||
|
>
|
||||||
|
<option value="">keine</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Vorlage (optional)" name="vorlageId" defaultValue="">
|
||||||
|
<option value="">keine</option>
|
||||||
|
{vorlagen.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.zusatz ? `${v.name} (${v.zusatz})` : v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Ablaufdatum (optional)"
|
||||||
|
name="ablaufAm"
|
||||||
|
type="date"
|
||||||
|
hinweis="Ohne Angabe gilt die Laufzeit der Vorlage, sonst keine."
|
||||||
|
fehler={f["ablaufAm"]}
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Tags"
|
||||||
|
name="tags"
|
||||||
|
mono
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="backup, pve"
|
||||||
|
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
|
||||||
|
fehler={f["tags"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variante="primaer"
|
||||||
|
disabled={beschaeftigt}
|
||||||
|
icon={<KeyRound className="size-4" />}
|
||||||
|
>
|
||||||
|
{knopfText}
|
||||||
|
</Button>
|
||||||
|
<Button href="/schluessel">Abbrechen</Button>
|
||||||
|
<p role="status" className="text-muted text-[13px]">
|
||||||
|
{vorgangLaeuft && "Der Worker arbeitet …"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { clsx } from "clsx";
|
||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -8,10 +9,13 @@ export function MonoBlock({
|
|||||||
wert,
|
wert,
|
||||||
beschriftung,
|
beschriftung,
|
||||||
hoehe,
|
hoehe,
|
||||||
|
umbrechen = false,
|
||||||
}: {
|
}: {
|
||||||
wert: string;
|
wert: string;
|
||||||
beschriftung: string;
|
beschriftung: string;
|
||||||
hoehe?: string;
|
hoehe?: string;
|
||||||
|
/** Lange Einzeiler (Public Keys) umbrechen statt seitlich scrollen (#012). */
|
||||||
|
umbrechen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [kopiert, setKopiert] = useState(false);
|
const [kopiert, setKopiert] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -33,7 +37,10 @@ export function MonoBlock({
|
|||||||
{kopiert ? <Check className="text-ok size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
|
{kopiert ? <Check className="text-ok size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
|
||||||
</button>
|
</button>
|
||||||
<pre
|
<pre
|
||||||
className="text-text overflow-auto p-4 pr-16 font-mono text-[12.5px] leading-[1.55]"
|
className={clsx(
|
||||||
|
"text-text overflow-auto p-4 pr-16 font-mono text-[12.5px] leading-[1.55]",
|
||||||
|
umbrechen && "break-all whitespace-pre-wrap",
|
||||||
|
)}
|
||||||
style={hoehe ? { maxHeight: hoehe } : undefined}
|
style={hoehe ? { maxHeight: hoehe } : undefined}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={beschriftung}
|
aria-label={beschriftung}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verfolgt einen Vorgang bis zum Ende (#012, #016): fragt /api/vorgaenge/[id] ab, bis der
|
||||||
|
* Status nicht mehr WARTEND oder LAEUFT ist. Der Stand ist nach Vorgangs-ID gemerkt – ein
|
||||||
|
* neuer Vorgang zeigt so nie den Stand des vorigen, ohne dass der Effekt synchron
|
||||||
|
* zuruecksetzen muss.
|
||||||
|
*/
|
||||||
|
export type VorgangsStand = {
|
||||||
|
readonly id?: string;
|
||||||
|
readonly status: string;
|
||||||
|
readonly fehler: string | null;
|
||||||
|
readonly plan?: unknown;
|
||||||
|
readonly ergebnis: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LAUFEND = new Set(["WARTEND", "LAEUFT"]);
|
||||||
|
|
||||||
|
/** Fuer Formulare: ohne Stand gilt der Vorgang als laufend (er wurde gerade angelegt). */
|
||||||
|
export function istLaufend(stand: VorgangsStand | undefined): boolean {
|
||||||
|
return stand === undefined || LAUFEND.has(stand.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuer Anzeigen: ohne Stand laeuft (noch) nichts. */
|
||||||
|
export function laeuftNoch(stand: VorgangsStand | undefined): boolean {
|
||||||
|
return stand !== undefined && LAUFEND.has(stand.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Gemerkt = { readonly id: string; readonly stand?: VorgangsStand; readonly fehler?: string };
|
||||||
|
|
||||||
|
export function useVorgang(vorgangId: string | undefined): {
|
||||||
|
stand: VorgangsStand | undefined;
|
||||||
|
/** Die Statusabfrage selbst scheiterte (nicht der Vorgang). */
|
||||||
|
abfrageFehler: string | undefined;
|
||||||
|
/** Gleichbedeutend mit abfrageFehler (aeltere Aufrufer, #016). */
|
||||||
|
fehler: string | undefined;
|
||||||
|
} {
|
||||||
|
const [gemerkt, setGemerkt] = useState<Gemerkt>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vorgangId) return;
|
||||||
|
let abbrechen = false;
|
||||||
|
const abfragen = async () => {
|
||||||
|
while (!abbrechen) {
|
||||||
|
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||||
|
if (abbrechen) return;
|
||||||
|
if (!antwort.ok) {
|
||||||
|
setGemerkt({ id: vorgangId, fehler: "Status des Vorgangs nicht abrufbar." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const neu = (await antwort.json()) as VorgangsStand;
|
||||||
|
if (abbrechen) return;
|
||||||
|
setGemerkt({ id: vorgangId, stand: neu });
|
||||||
|
if (!LAUFEND.has(neu.status)) return;
|
||||||
|
await new Promise((weiter) => setTimeout(weiter, 800));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void abfragen();
|
||||||
|
return () => {
|
||||||
|
abbrechen = true;
|
||||||
|
};
|
||||||
|
}, [vorgangId]);
|
||||||
|
|
||||||
|
const aktuell = gemerkt && gemerkt.id === vorgangId ? gemerkt : undefined;
|
||||||
|
return { stand: aktuell?.stand, abfrageFehler: aktuell?.fehler, fehler: aktuell?.fehler };
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingaben fuer Personen und den Self-Service (#013) – framework-frei, damit Service,
|
||||||
|
* Actions und Tests dieselben Regeln teilen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const optionalerText = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max, `Höchstens ${max} Zeichen.`)
|
||||||
|
.nullish()
|
||||||
|
.transform((wert) => (wert ? wert : null));
|
||||||
|
|
||||||
|
export const personEingabeSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Bitte einen Namen angeben.").max(120, "Höchstens 120 Zeichen."),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.max(254)
|
||||||
|
.nullish()
|
||||||
|
.transform((wert) => (wert ? wert : null))
|
||||||
|
.refine(
|
||||||
|
(wert) => wert === null || z.email().safeParse(wert).success,
|
||||||
|
"Bitte eine gültige E-Mail-Adresse.",
|
||||||
|
),
|
||||||
|
organisation: optionalerText(120),
|
||||||
|
notiz: optionalerText(1000),
|
||||||
|
benutzerId: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(64)
|
||||||
|
.nullish()
|
||||||
|
.transform((wert) => (wert ? wert : null)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PersonEingabe = z.input<typeof personEingabeSchema>;
|
||||||
|
export type PersonDaten = z.output<typeof personEingabeSchema>;
|
||||||
|
|
||||||
|
/** Eigener Public Key im Self-Service: Zweck und die Zeile, sonst nichts. */
|
||||||
|
export const eigenerKeySchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Bitte eine Bezeichnung angeben.").max(120, "Höchstens 120 Zeichen."),
|
||||||
|
publicKey: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Bitte den Public Key einfügen.")
|
||||||
|
.max(16 * 1024, "Der Text ist zu lang für einen Public Key."),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EigenerKeyEingabe = z.input<typeof eigenerKeySchema>;
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { MAX_TAGS, TAG } from "@/lib/hosts";
|
||||||
|
import { tageBis } from "@/lib/format";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eingaben und Anzeigeregeln fuer Schluessel (#012).
|
||||||
|
*
|
||||||
|
* Framework-frei: Web-Service, Server Actions, Worker-Ablaeufe und Tests teilen dieselben
|
||||||
|
* Regeln. Der Worker prueft die Metadaten aus `Vorgang.eingabe` noch einmal, weil zwischen
|
||||||
|
* Anfrage und Ausfuehrung Zeit vergeht und die Eingabe in der Datenbank liegt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wie der DB-CHECK `schluessel_slug_gueltig`. */
|
||||||
|
export const SLUG = /^[a-z0-9-]{1,64}$/;
|
||||||
|
export const NAME_MAX = 120;
|
||||||
|
export const KOMMENTAR_MAX = 200;
|
||||||
|
/** Ein OpenSSH-Private-Key mit RSA-16384 bleibt unter 16 KiB; mehr ist kein Schluessel. */
|
||||||
|
export const PRIVATE_KEY_MAX = 16 * 1024;
|
||||||
|
export const PASSPHRASE_MAX = 1024;
|
||||||
|
/**
|
||||||
|
* Wie lange ein vom Worker geoeffneter Private Key zur Abholung bereitliegt (#014). Worker
|
||||||
|
* (Aufraeumen) und Web (Abholen) pruefen dieselbe Frist.
|
||||||
|
*/
|
||||||
|
export const ABHOLFRIST_MS = 2 * 60 * 1000;
|
||||||
|
/** Ab hier wird ein Ablaufdatum in Messing hervorgehoben. */
|
||||||
|
export const ABLAUF_WARNUNG_TAGE = 30;
|
||||||
|
|
||||||
|
const UMLAUTE: Record<string, string> = { ä: "ae", ö: "oe", ü: "ue", ß: "ss" };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slug aus dem Zweck: „Backup srv-backup01 → srv-pve01“ wird zu
|
||||||
|
* `backup-srv-backup01-srv-pve01`. Ohne verwertbare Zeichen bleibt „schluessel“.
|
||||||
|
*/
|
||||||
|
export function slugAus(name: string): string {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[äöüß]/g, (z) => UMLAUTE[z] ?? z)
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 64)
|
||||||
|
.replace(/-+$/, "");
|
||||||
|
return slug || "schluessel";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Haengt `-2`, `-3` … an, bis `istFrei` zustimmt; kuerzt vorher, damit 64 Zeichen reichen. */
|
||||||
|
export async function freierSlug(
|
||||||
|
basis: string,
|
||||||
|
istFrei: (slug: string) => Promise<boolean>,
|
||||||
|
): Promise<string> {
|
||||||
|
if (await istFrei(basis)) return basis;
|
||||||
|
for (let n = 2; n < 1000; n++) {
|
||||||
|
const anhang = `-${n}`;
|
||||||
|
const kandidat = `${basis.slice(0, 64 - anhang.length).replace(/-+$/, "")}${anhang}`;
|
||||||
|
if (await istFrei(kandidat)) return kandidat;
|
||||||
|
}
|
||||||
|
throw new Error("Kein freier Slug gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Freitext aus einem Formular: leer heisst „nicht gesetzt“. */
|
||||||
|
const optionalerText = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max, `Höchstens ${max} Zeichen.`)
|
||||||
|
.nullish()
|
||||||
|
.transform((wert) => (wert ? wert : null));
|
||||||
|
|
||||||
|
const optionaleId = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(64)
|
||||||
|
.nullish()
|
||||||
|
.transform((wert) => (wert ? wert : null));
|
||||||
|
|
||||||
|
const tagsSchema = z
|
||||||
|
.preprocess(
|
||||||
|
(wert) => (typeof wert === "string" ? wert.split(/[\s,]+/).filter(Boolean) : (wert ?? [])),
|
||||||
|
z.array(
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(TAG, "Tags: 1–32 Zeichen aus a–z, 0–9, Punkt, Unterstrich, Bindestrich."),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.transform((tags) => [...new Set(tags)])
|
||||||
|
.refine((tags) => tags.length <= MAX_TAGS, `Höchstens ${MAX_TAGS} Tags.`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ablaufdatum als `JJJJ-MM-TT` aus `<input type="date">`. Gespeichert wird Mitternacht UTC
|
||||||
|
* des Tages – `datum()` zeigt in Europe/Berlin denselben Kalendertag.
|
||||||
|
*/
|
||||||
|
const ablaufSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.nullish()
|
||||||
|
.transform((wert, kontext) => {
|
||||||
|
if (!wert) return null;
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(wert)) {
|
||||||
|
kontext.addIssue({ code: "custom", message: "Bitte ein Datum angeben." });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
const datum = new Date(`${wert}T00:00:00Z`);
|
||||||
|
if (Number.isNaN(datum.getTime()) || datum.toISOString().slice(0, 10) !== wert) {
|
||||||
|
kontext.addIssue({ code: "custom", message: "Bitte ein gültiges Datum angeben." });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return datum;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Kommentar der Public-Key-Zeile: keine Steuerzeichen, sie braechen authorized_keys auf. */
|
||||||
|
const kommentarSchema = optionalerText(KOMMENTAR_MAX).refine(
|
||||||
|
(wert) => wert === null || !/[\x00-\x1f\x7f]/.test(wert),
|
||||||
|
"Der Kommentar enthält unzulässige Zeichen.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const slugSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.nullish()
|
||||||
|
.transform((wert) => (wert ? wert : null))
|
||||||
|
.refine((wert) => wert === null || SLUG.test(wert), "1–64 Zeichen aus a–z, 0–9 und Bindestrich.");
|
||||||
|
|
||||||
|
/** Metadaten, die jeder Schluessel hat – beim Anlegen und Bearbeiten. */
|
||||||
|
const metadatenFelder = {
|
||||||
|
name: z.string().trim().min(1, "Bitte den Zweck angeben.").max(NAME_MAX, `Höchstens ${NAME_MAX} Zeichen.`),
|
||||||
|
tags: tagsSchema,
|
||||||
|
ablaufAm: ablaufSchema,
|
||||||
|
personId: optionaleId,
|
||||||
|
vorlageId: optionaleId,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugen (#012). Kategorie ist fest DIENST: Personen-Keys speichert sshkv nie mit Private
|
||||||
|
* Key (DB-CHECK `schluessel_person_ohne_private_key`), Deployment-Keys verwaltet der Worker.
|
||||||
|
*/
|
||||||
|
export const erzeugenSchema = z.object({
|
||||||
|
...metadatenFelder,
|
||||||
|
slug: slugSchema,
|
||||||
|
typ: z.enum(["ED25519", "RSA4096"]).default("ED25519"),
|
||||||
|
kommentar: kommentarSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Import eines Paars: dieselben Regeln wie beim Erzeugen, der Typ steckt im Key selbst. */
|
||||||
|
export const paarImportSchema = z.object({
|
||||||
|
...metadatenFelder,
|
||||||
|
slug: slugSchema,
|
||||||
|
kommentar: kommentarSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Import nur des Public Keys: auch fuer Personen-Keys. */
|
||||||
|
export const publicImportSchema = z.object({
|
||||||
|
...metadatenFelder,
|
||||||
|
slug: slugSchema,
|
||||||
|
kategorie: z.enum(["PERSON", "DIENST"]).default("DIENST"),
|
||||||
|
publicKey: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Bitte den Public Key einfügen.")
|
||||||
|
.max(16 * 1024, "Der Text ist zu lang für einen Public Key."),
|
||||||
|
kommentar: kommentarSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearbeiten: Slug, Typ und Kommentar bleiben – sie stehen schon in Dateinamen und
|
||||||
|
* authorized_keys-Zeilen auf den Hosts. ROTIERT/WIDERRUFEN setzen nur Rotation und Widerruf
|
||||||
|
* (#023, #024); von Hand gibt es nur AKTIV und ABGELAUFEN.
|
||||||
|
*/
|
||||||
|
export const bearbeitenSchema = z.object({
|
||||||
|
...metadatenFelder,
|
||||||
|
status: z.enum(["AKTIV", "ABGELAUFEN"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Tags kommen als Text („pve, backup“) oder Liste; `z.preprocess` liesse sonst nur `unknown` uebrig. */
|
||||||
|
type MitTags<T> = Omit<T, "tags"> & { tags?: string | string[] };
|
||||||
|
export type ErzeugenEingabe = MitTags<z.input<typeof erzeugenSchema>>;
|
||||||
|
export type ErzeugenDaten = z.output<typeof erzeugenSchema>;
|
||||||
|
export type PaarImportEingabe = MitTags<z.input<typeof paarImportSchema>>;
|
||||||
|
export type PaarImportDaten = z.output<typeof paarImportSchema>;
|
||||||
|
export type PublicImportEingabe = MitTags<z.input<typeof publicImportSchema>>;
|
||||||
|
export type PublicImportDaten = z.output<typeof publicImportSchema>;
|
||||||
|
export type BearbeitenEingabe = MitTags<z.input<typeof bearbeitenSchema>>;
|
||||||
|
export type BearbeitenDaten = z.output<typeof bearbeitenSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was ein Erzeugen- oder Import-Vorgang in `Vorgang.eingabe` traegt – nur Metadaten, nie ein
|
||||||
|
* Geheimnis. Datumswerte als ISO-Text, weil `eingabe` JSON ist.
|
||||||
|
*/
|
||||||
|
export type SchluesselVorgangEingabe = {
|
||||||
|
readonly name: string;
|
||||||
|
readonly slug: string;
|
||||||
|
readonly tags: string[];
|
||||||
|
readonly ablaufAm: string | null;
|
||||||
|
readonly personId: string | null;
|
||||||
|
readonly vorlageId: string | null;
|
||||||
|
readonly kommentar: string | null;
|
||||||
|
readonly typ?: "ED25519" | "RSA4096";
|
||||||
|
/** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */
|
||||||
|
readonly mitPassphrase?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */
|
||||||
|
export const vorgangEingabeSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(NAME_MAX),
|
||||||
|
slug: z.string().regex(SLUG),
|
||||||
|
tags: z.array(z.string().regex(TAG)).max(MAX_TAGS),
|
||||||
|
ablaufAm: z.iso.datetime().nullable(),
|
||||||
|
personId: z.string().max(64).nullable(),
|
||||||
|
vorlageId: z.string().max(64).nullable(),
|
||||||
|
kommentar: z
|
||||||
|
.string()
|
||||||
|
.max(KOMMENTAR_MAX)
|
||||||
|
.refine((wert) => !/[\x00-\x1f\x7f]/.test(wert))
|
||||||
|
.nullable(),
|
||||||
|
typ: z.enum(["ED25519", "RSA4096"]).optional(),
|
||||||
|
mitPassphrase: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */
|
||||||
|
export type SchluesselGeheimnis = {
|
||||||
|
readonly privateKey?: string;
|
||||||
|
readonly passphrase?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Anzeige
|
||||||
|
|
||||||
|
export type Kategorie = "PERSON" | "DIENST" | "DEPLOYMENT";
|
||||||
|
export type Status = "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
|
||||||
|
|
||||||
|
export const KATEGORIE_TEXT: Record<Kategorie, string> = {
|
||||||
|
PERSON: "Person",
|
||||||
|
DIENST: "Dienst",
|
||||||
|
DEPLOYMENT: "Deployment",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_TEXT: Record<Status, string> = {
|
||||||
|
AKTIV: "aktiv",
|
||||||
|
ROTIERT: "rotiert",
|
||||||
|
WIDERRUFEN: "widerrufen",
|
||||||
|
ABGELAUFEN: "abgelaufen",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kurzform fuer Listen: `ed25519`, `rsa-4096`, sonst der Typ aus der Public-Key-Zeile. */
|
||||||
|
export function typAnzeige(typ: "ED25519" | "RSA4096" | "ANDERER", publicKey: string): string {
|
||||||
|
if (typ === "ED25519") return "ed25519";
|
||||||
|
if (typ === "RSA4096") return "rsa-4096";
|
||||||
|
const algorithmus = publicKey.split(/\s+/)[0] ?? "";
|
||||||
|
if (algorithmus === "ssh-rsa") return "rsa";
|
||||||
|
return algorithmus.replace(/^ssh-/, "").replace(/@openssh\.com$/, "") || "unbekannt";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `SHA256:q3vN8xP0…Qe0k` – lang genug zum Wiedererkennen, kurz genug fuer eine Tabellenzeile. */
|
||||||
|
export function fingerprintKurz(fingerprint: string): string {
|
||||||
|
const [praefix, rest] = fingerprint.includes(":") ? fingerprint.split(":", 2) : ["", fingerprint];
|
||||||
|
const wert = rest ?? "";
|
||||||
|
if (wert.length <= 14) return fingerprint;
|
||||||
|
return `${praefix ? `${praefix}:` : ""}${wert.slice(0, 8)}…${wert.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AblaufZustand =
|
||||||
|
| { readonly art: "keiner" }
|
||||||
|
| { readonly art: "spaeter"; readonly tage: number }
|
||||||
|
| { readonly art: "bald"; readonly tage: number }
|
||||||
|
| { readonly art: "abgelaufen"; readonly tage: number };
|
||||||
|
|
||||||
|
/** Wie dringend ist das Ablaufdatum? `bald` heisst: in hoechstens 30 Tagen. */
|
||||||
|
export function ablaufZustand(ablaufAm: Date | string | null, jetzt: Date = new Date()): AblaufZustand {
|
||||||
|
if (!ablaufAm) return { art: "keiner" };
|
||||||
|
const tage = tageBis(ablaufAm, jetzt);
|
||||||
|
if (tage < 0) return { art: "abgelaufen", tage };
|
||||||
|
if (tage <= ABLAUF_WARNUNG_TAGE) return { art: "bald", tage };
|
||||||
|
return { art: "spaeter", tage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typ fuer die Datenbank aus Algorithmus und Laenge: nur ed25519 und RSA-4096 haben eigene Werte. */
|
||||||
|
export function schluesselTypVon(
|
||||||
|
algorithmus: string,
|
||||||
|
bits: number | null,
|
||||||
|
): "ED25519" | "RSA4096" | "ANDERER" {
|
||||||
|
if (algorithmus === "ssh-ed25519") return "ED25519";
|
||||||
|
if (algorithmus === "ssh-rsa" && bits === 4096) return "RSA4096";
|
||||||
|
return "ANDERER";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public-Key-Zeile mit dem gespeicherten Kommentar: `typ base64 kommentar`. */
|
||||||
|
export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): string {
|
||||||
|
const [typ, base64] = zeile.trim().split(/\s+/);
|
||||||
|
return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import { fingerprintSha256 } from "@/lib/ssh/openssh";
|
||||||
|
import { OptionenFehler, optionenAmAnfangLesen } from "@/lib/ssh/optionen";
|
||||||
|
import {
|
||||||
|
BLOCK_ANFANG,
|
||||||
|
BLOCK_ANFANG_PRAEFIX,
|
||||||
|
BLOCK_ENDE,
|
||||||
|
BLOCK_ENDE_PRAEFIX,
|
||||||
|
} from "@/lib/ssh/verwalteter-block";
|
||||||
|
import { zeilenDiff, type DiffZeile } from "@/lib/ssh/zeilen-diff";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* authorized_keys lesen und den verwalteten Block setzen (#015).
|
||||||
|
*
|
||||||
|
* Zusagen, die die Tests festhalten:
|
||||||
|
* - Alles ausserhalb des Blocks bleibt **bytegenau** erhalten – auch CRLF, BOM, Kommentare,
|
||||||
|
* kaputte Zeilen und eine fehlende letzte Zeilenschaltung.
|
||||||
|
* - Der Block wird deterministisch aus dem Soll erzeugt.
|
||||||
|
* - Ein beschaedigter Block (Anfang ohne Ende, mehrere Bloecke …) wird nie automatisch
|
||||||
|
* ueberschrieben: das waere die Stelle, an der sshkv fremde Zeilen verschluckt.
|
||||||
|
* - Verwaltete Schluessel werden am Fingerprint erkannt, nicht am Kommentar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Schluesseltypen, an denen sshd den Anfang des Schluessels erkennt (ohne Optionen davor). */
|
||||||
|
const SCHLUESSELTYP =
|
||||||
|
/^(ssh-(ed25519|rsa|dss)|ecdsa-sha2-nistp(256|384|521)|sk-(ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com)(-cert-v01@openssh\.com)?$/;
|
||||||
|
|
||||||
|
export type SchluesselZeile = {
|
||||||
|
readonly art: "schluessel";
|
||||||
|
readonly roh: string;
|
||||||
|
readonly imBlock: boolean;
|
||||||
|
/** Optionen-Text wie in der Datei, oder null. */
|
||||||
|
readonly optionen: string | null;
|
||||||
|
readonly typ: string;
|
||||||
|
readonly base64: string;
|
||||||
|
readonly kommentar: string;
|
||||||
|
readonly fingerprint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Zeile =
|
||||||
|
| SchluesselZeile
|
||||||
|
| {
|
||||||
|
readonly art: "leer" | "kommentar" | "blockAnfang" | "blockEnde";
|
||||||
|
readonly roh: string;
|
||||||
|
readonly imBlock: boolean;
|
||||||
|
}
|
||||||
|
| { readonly art: "ungueltig"; readonly roh: string; readonly imBlock: boolean; readonly grund: string };
|
||||||
|
|
||||||
|
export type BlockZustand =
|
||||||
|
| { readonly art: "keiner" }
|
||||||
|
| { readonly art: "ok"; readonly anfang: number; readonly ende: number }
|
||||||
|
| { readonly art: "beschaedigt"; readonly grund: string };
|
||||||
|
|
||||||
|
export type AuthorizedKeys = {
|
||||||
|
readonly zeilen: readonly Zeile[];
|
||||||
|
readonly bom: boolean;
|
||||||
|
/** Endet die Datei mit einer Zeilenschaltung? */
|
||||||
|
readonly endetMitZeilenende: boolean;
|
||||||
|
/** Ueberwiegend CRLF? Neue Zeilen folgen dann dieser Gewohnheit. */
|
||||||
|
readonly crlf: boolean;
|
||||||
|
readonly block: BlockZustand;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ohneCr(roh: string): string {
|
||||||
|
return roh.endsWith("\r") ? roh.slice(0, -1) : roh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest eine Schluesselzeile; wirft mit lesbarem Grund. */
|
||||||
|
function schluesselLesen(text: string): Omit<SchluesselZeile, "art" | "roh" | "imBlock"> {
|
||||||
|
let rest = text;
|
||||||
|
let optionen: string | null = null;
|
||||||
|
const erstesWort = rest.split(/[ \t]/, 1)[0] ?? "";
|
||||||
|
if (!SCHLUESSELTYP.test(erstesWort)) {
|
||||||
|
const { laenge } = optionenAmAnfangLesen(rest);
|
||||||
|
optionen = rest.slice(0, laenge);
|
||||||
|
rest = rest.slice(laenge).replace(/^[ \t]+/, "");
|
||||||
|
}
|
||||||
|
const treffer = /^(\S+)[ \t]+(\S+)(?:[ \t]+(.*))?$/.exec(rest);
|
||||||
|
if (!treffer) throw new Error("Kein Schlüssel nach den Optionen.");
|
||||||
|
const [, typ, base64, kommentar = ""] = treffer;
|
||||||
|
if (!SCHLUESSELTYP.test(typ!)) throw new Error(`Unbekannter Schlüsseltyp „${typ}“.`);
|
||||||
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(base64!)) throw new Error("Schlüssel ist nicht Base64-kodiert.");
|
||||||
|
const blob = Buffer.from(base64!, "base64");
|
||||||
|
// Der Typ steht auch im Blob; weicht er ab, ist die Zeile kaputt oder manipuliert.
|
||||||
|
const laenge = blob.length >= 4 ? blob.readUInt32BE(0) : 0;
|
||||||
|
if (laenge === 0 || blob.length < 4 + laenge || blob.subarray(4, 4 + laenge).toString("ascii") !== typ) {
|
||||||
|
throw new Error("Typ im Schlüssel passt nicht zum Zeilentyp.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
optionen,
|
||||||
|
typ: typ!,
|
||||||
|
base64: base64!,
|
||||||
|
kommentar: kommentar.trim(),
|
||||||
|
fingerprint: fingerprintSha256(blob),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorizedKeysLesen(inhalt: string): AuthorizedKeys {
|
||||||
|
const bom = inhalt.startsWith("");
|
||||||
|
const text = bom ? inhalt.slice(1) : inhalt;
|
||||||
|
const endetMitZeilenende = text.length === 0 || text.endsWith("\n");
|
||||||
|
const rohZeilen = text.split("\n");
|
||||||
|
if (endetMitZeilenende) rohZeilen.pop();
|
||||||
|
|
||||||
|
let crlfAnzahl = 0;
|
||||||
|
const anfaenge: number[] = [];
|
||||||
|
const enden: number[] = [];
|
||||||
|
let imBlock = false;
|
||||||
|
const zeilen: Zeile[] = rohZeilen.map((roh, index) => {
|
||||||
|
if (roh.endsWith("\r")) crlfAnzahl += 1;
|
||||||
|
const sauber = ohneCr(roh).trim();
|
||||||
|
if (sauber.startsWith(BLOCK_ANFANG_PRAEFIX)) {
|
||||||
|
anfaenge.push(index);
|
||||||
|
imBlock = true;
|
||||||
|
return { art: "blockAnfang", roh, imBlock: true };
|
||||||
|
}
|
||||||
|
if (sauber.startsWith(BLOCK_ENDE_PRAEFIX)) {
|
||||||
|
enden.push(index);
|
||||||
|
const war = imBlock;
|
||||||
|
imBlock = false;
|
||||||
|
return { art: "blockEnde", roh, imBlock: war };
|
||||||
|
}
|
||||||
|
if (sauber === "") return { art: "leer", roh, imBlock };
|
||||||
|
if (sauber.startsWith("#")) return { art: "kommentar", roh, imBlock };
|
||||||
|
try {
|
||||||
|
return { art: "schluessel", roh, imBlock, ...schluesselLesen(sauber) };
|
||||||
|
} catch (fehler) {
|
||||||
|
const grund = fehler instanceof OptionenFehler || fehler instanceof Error ? fehler.message : "unlesbar";
|
||||||
|
return { art: "ungueltig", roh, imBlock, grund };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let block: BlockZustand = { art: "keiner" };
|
||||||
|
if (anfaenge.length === 1 && enden.length === 1 && anfaenge[0]! < enden[0]!) {
|
||||||
|
block = { art: "ok", anfang: anfaenge[0]!, ende: enden[0]! };
|
||||||
|
} else if (anfaenge.length > 1 || enden.length > 1) {
|
||||||
|
block = { art: "beschaedigt", grund: "Die Datei enthält mehr als einen verwalteten Block." };
|
||||||
|
} else if (anfaenge.length !== enden.length) {
|
||||||
|
block = {
|
||||||
|
art: "beschaedigt",
|
||||||
|
grund: anfaenge.length
|
||||||
|
? "Der verwaltete Block hat kein Ende."
|
||||||
|
: "Ende eines verwalteten Blocks ohne Anfang.",
|
||||||
|
};
|
||||||
|
} else if (anfaenge.length === 1) {
|
||||||
|
block = { art: "beschaedigt", grund: "Ende des verwalteten Blocks steht vor dem Anfang." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
zeilen,
|
||||||
|
bom,
|
||||||
|
endetMitZeilenende,
|
||||||
|
crlf: crlfAnzahl > 0 && crlfAnzahl * 2 >= rohZeilen.length,
|
||||||
|
block,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Eintrag des Solls im verwalteten Block. */
|
||||||
|
export type SollEintrag = {
|
||||||
|
/** Geprueftes Optionen-Feld (siehe optionenPruefen), oder leer. */
|
||||||
|
readonly optionen?: string | null;
|
||||||
|
/** "typ base64 [kommentar]" */
|
||||||
|
readonly publicKey: string;
|
||||||
|
readonly fingerprint: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class BlockFehler extends Error {
|
||||||
|
constructor(
|
||||||
|
nachricht: string,
|
||||||
|
readonly grund: "beschaedigt" | "aussperrung" | "ungueltig",
|
||||||
|
) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "BlockFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BlockErgebnis = {
|
||||||
|
readonly inhalt: string;
|
||||||
|
readonly geaendert: boolean;
|
||||||
|
readonly diff: DiffZeile[];
|
||||||
|
/** Hinweise fuer den Dry-Run: doppelte Keys ausserhalb, ungueltige Zeilen … */
|
||||||
|
readonly hinweise: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function eintragZeile(eintrag: SollEintrag): string {
|
||||||
|
const key = eintrag.publicKey.trim().replace(/[ \t]+/g, " ");
|
||||||
|
if (/[\r\n\x00]/.test(key) || (eintrag.optionen && /[\r\n\x00]/.test(eintrag.optionen))) {
|
||||||
|
throw new BlockFehler("Ein Eintrag enthält einen Zeilenumbruch.", "ungueltig");
|
||||||
|
}
|
||||||
|
return eintrag.optionen ? `${eintrag.optionen} ${key}` : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den verwalteten Block auf das Soll. `pflicht` sind Fingerprints, die nie aus dem
|
||||||
|
* Block verschwinden duerfen (Deployment-Key, Aussperrschutz).
|
||||||
|
*/
|
||||||
|
export function blockSetzen(
|
||||||
|
inhalt: string,
|
||||||
|
soll: readonly SollEintrag[],
|
||||||
|
pflicht: readonly string[] = [],
|
||||||
|
): BlockErgebnis {
|
||||||
|
const datei = authorizedKeysLesen(inhalt);
|
||||||
|
if (datei.block.art === "beschaedigt") {
|
||||||
|
throw new BlockFehler(`${datei.block.grund} Bitte die Datei von Hand bereinigen.`, "beschaedigt");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doppelte Soll-Eintraege: der erste gewinnt (deterministisch).
|
||||||
|
const gesehen = new Set<string>();
|
||||||
|
const eintraege = soll.filter((eintrag) => {
|
||||||
|
if (gesehen.has(eintrag.fingerprint)) return false;
|
||||||
|
gesehen.add(eintrag.fingerprint);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const imBlockVorher = datei.zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel" && z.imBlock);
|
||||||
|
for (const fingerprint of pflicht) {
|
||||||
|
const warDa = imBlockVorher.some((z) => z.fingerprint === fingerprint);
|
||||||
|
if (warDa && !gesehen.has(fingerprint)) {
|
||||||
|
throw new BlockFehler(
|
||||||
|
"Diese Änderung würde den Deployment-Key aus dem verwalteten Block entfernen und sshkv aussperren.",
|
||||||
|
"aussperrung",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ende = datei.crlf ? "\r" : "";
|
||||||
|
const neuerBlock = [BLOCK_ANFANG, ...eintraege.map(eintragZeile), BLOCK_ENDE].map((z) => z + ende);
|
||||||
|
|
||||||
|
let rohNeu: string[];
|
||||||
|
let endetMitZeilenende = datei.endetMitZeilenende;
|
||||||
|
const roh = datei.zeilen.map((z) => z.roh);
|
||||||
|
if (datei.block.art === "ok") {
|
||||||
|
rohNeu = [...roh.slice(0, datei.block.anfang), ...neuerBlock, ...roh.slice(datei.block.ende + 1)];
|
||||||
|
} else {
|
||||||
|
rohNeu = [...roh, ...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;
|
||||||
|
}
|
||||||
|
const neu = (datei.bom ? "" : "") + rohNeu.join("\n") + (endetMitZeilenende && rohNeu.length ? "\n" : "");
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (doppelt) {
|
||||||
|
hinweise.push(
|
||||||
|
`${eintrag.fingerprint} steht zusätzlich außerhalb des Blocks${doppelt.optionen ? "" : " – dort ohne Einschränkungen"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ungueltig = datei.zeilen.filter((z) => z.art === "ungueltig").length;
|
||||||
|
if (ungueltig)
|
||||||
|
hinweise.push(`${ungueltig} Zeile(n) außerhalb des Blocks sind ungültig und bleiben unverändert.`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inhalt: neu,
|
||||||
|
geaendert: neu !== inhalt,
|
||||||
|
diff: zeilenDiff(roh.map(ohneCr), rohNeu.map(ohneCr)),
|
||||||
|
hinweise,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Schluessel einer Datei mit Lage, fuer Abgleich und Import (#021, #022). */
|
||||||
|
export function schluesselZeilen(inhalt: string): SchluesselZeile[] {
|
||||||
|
return authorizedKeysLesen(inhalt).zeilen.filter((z): z is SchluesselZeile => z.art === "schluessel");
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Optionen einer authorized_keys-Zeile (#015): `restrict,from="10.0.0.1",command="…"`.
|
||||||
|
*
|
||||||
|
* Geparst wird wie in sshd (auth-options.c): Komma trennt, ausser in doppelten
|
||||||
|
* Anfuehrungszeichen; dort maskiert `\"` ein Anfuehrungszeichen. Die Optionen stehen am
|
||||||
|
* Zeilenanfang und enden am ersten Leerraum ausserhalb von Anfuehrungszeichen.
|
||||||
|
*/
|
||||||
|
export class OptionenFehler extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "OptionenFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Option = { readonly name: string; readonly wert?: string };
|
||||||
|
|
||||||
|
/** Optionen ohne Wert, die sshd kennt. */
|
||||||
|
const SCHALTER = new Set([
|
||||||
|
"restrict",
|
||||||
|
"cert-authority",
|
||||||
|
"no-port-forwarding",
|
||||||
|
"no-agent-forwarding",
|
||||||
|
"no-x11-forwarding",
|
||||||
|
"no-pty",
|
||||||
|
"no-user-rc",
|
||||||
|
"no-touch-required",
|
||||||
|
"verify-required",
|
||||||
|
"port-forwarding",
|
||||||
|
"agent-forwarding",
|
||||||
|
"x11-forwarding",
|
||||||
|
"pty",
|
||||||
|
"user-rc",
|
||||||
|
"touch-required",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Optionen mit Wert in Anfuehrungszeichen. */
|
||||||
|
const MIT_WERT = new Set([
|
||||||
|
"from",
|
||||||
|
"command",
|
||||||
|
"environment",
|
||||||
|
"permitopen",
|
||||||
|
"permitlisten",
|
||||||
|
"principals",
|
||||||
|
"tunnel",
|
||||||
|
"expiry-time",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den Optionen-Teil am Anfang einer Zeile. Liefert die Optionen und die Laenge des
|
||||||
|
* gelesenen Textes (bis zum ersten Leerraum ausserhalb von Anfuehrungszeichen).
|
||||||
|
*/
|
||||||
|
export function optionenAmAnfangLesen(text: string): { optionen: Option[]; laenge: number } {
|
||||||
|
const optionen: Option[] = [];
|
||||||
|
let index = 0;
|
||||||
|
while (index < text.length) {
|
||||||
|
const nameAnfang = index;
|
||||||
|
while (index < text.length && /[A-Za-z0-9-]/.test(text[index]!)) index += 1;
|
||||||
|
const name = text.slice(nameAnfang, index).toLowerCase();
|
||||||
|
if (!name) throw new OptionenFehler(`Unerwartetes Zeichen in den Optionen an Stelle ${index + 1}.`);
|
||||||
|
let wert: string | undefined;
|
||||||
|
if (text[index] === "=") {
|
||||||
|
index += 1;
|
||||||
|
if (text[index] !== '"')
|
||||||
|
throw new OptionenFehler(`Der Wert von „${name}“ muss in Anführungszeichen stehen.`);
|
||||||
|
index += 1;
|
||||||
|
let inhalt = "";
|
||||||
|
let geschlossen = false;
|
||||||
|
while (index < text.length) {
|
||||||
|
const zeichen = text[index]!;
|
||||||
|
if (zeichen === "\\" && text[index + 1] === '"') {
|
||||||
|
inhalt += '"';
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (zeichen === '"') {
|
||||||
|
geschlossen = true;
|
||||||
|
index += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
inhalt += zeichen;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
if (!geschlossen) throw new OptionenFehler(`Fehlendes schließendes Anführungszeichen bei „${name}“.`);
|
||||||
|
wert = inhalt;
|
||||||
|
}
|
||||||
|
optionen.push(wert === undefined ? { name } : { name, wert });
|
||||||
|
if (text[index] === ",") {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return { optionen, laenge: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionSchreiben(option: Option): string {
|
||||||
|
return option.wert === undefined ? option.name : `${option.name}="${option.wert.replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft vom Benutzer eingegebene Optionen und liefert sie normalisiert zurueck. Strenger
|
||||||
|
* als sshd: unbekannte Optionen und Steuerzeichen werden abgewiesen – ein Tippfehler soll
|
||||||
|
* nicht still dazu fuehren, dass eine Einschraenkung fehlt.
|
||||||
|
*/
|
||||||
|
export function optionenPruefen(eingabe: string): string {
|
||||||
|
const text = eingabe.trim();
|
||||||
|
if (!text) return "";
|
||||||
|
if (/[\x00-\x1f\x7f]/.test(text)) throw new OptionenFehler("Die Optionen enthalten Steuerzeichen.");
|
||||||
|
const { optionen, laenge } = optionenAmAnfangLesen(text);
|
||||||
|
if (laenge !== text.length)
|
||||||
|
throw new OptionenFehler(
|
||||||
|
"Die Optionen dürfen keine Leerzeichen außerhalb von Anführungszeichen enthalten.",
|
||||||
|
);
|
||||||
|
for (const option of optionen) {
|
||||||
|
if (SCHALTER.has(option.name)) {
|
||||||
|
if (option.wert !== undefined) throw new OptionenFehler(`„${option.name}“ hat keinen Wert.`);
|
||||||
|
} else if (MIT_WERT.has(option.name)) {
|
||||||
|
if (option.wert === undefined || option.wert === "")
|
||||||
|
throw new OptionenFehler(`„${option.name}“ braucht einen Wert.`);
|
||||||
|
} else {
|
||||||
|
throw new OptionenFehler(`Unbekannte Option „${option.name}“.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return optionen.map(optionSchreiben).join(",");
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eintraege fuer ~/.ssh/config und known_hosts auf dem Quell-Host (#017).
|
||||||
|
*
|
||||||
|
* Der Block steht am **Anfang** der config: ssh nimmt je Option den ersten Treffer, und ein
|
||||||
|
* `Host *` weiter oben koennte sonst IdentityFile oder User vorgeben.
|
||||||
|
*/
|
||||||
|
export type SshConfigEintrag = {
|
||||||
|
readonly alias: string;
|
||||||
|
readonly hostName: string;
|
||||||
|
readonly benutzer: string;
|
||||||
|
readonly port: number;
|
||||||
|
readonly identityFile: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SshConfigFehler extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "SshConfigFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALIAS = /^[A-Za-z0-9._-]{1,64}$/;
|
||||||
|
const HOSTNAME = /^[A-Za-z0-9._:-]{1,253}$/;
|
||||||
|
const PFAD = /^(~\/|\/)[A-Za-z0-9._/@+-]+$/;
|
||||||
|
|
||||||
|
export function sshConfigPruefen(eintrag: SshConfigEintrag): SshConfigEintrag {
|
||||||
|
if (!ALIAS.test(eintrag.alias)) throw new SshConfigFehler("Ungültiger Alias (Buchstaben, Ziffern, . _ -).");
|
||||||
|
if (!HOSTNAME.test(eintrag.hostName)) throw new SshConfigFehler("Ungültiger HostName.");
|
||||||
|
if (!BENUTZERNAME_MUSTER.test(eintrag.benutzer)) throw new SshConfigFehler("Ungültiger Benutzer.");
|
||||||
|
if (!Number.isInteger(eintrag.port) || eintrag.port < 1 || eintrag.port > 65535)
|
||||||
|
throw new SshConfigFehler("Ungültiger Port.");
|
||||||
|
if (!PFAD.test(eintrag.identityFile) || /(^|\/)\.\.(\/|$)/.test(eintrag.identityFile)) {
|
||||||
|
throw new SshConfigFehler("Ungültiger Pfad zum Private Key.");
|
||||||
|
}
|
||||||
|
return eintrag;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sshConfigZeilen(eintraege: readonly SshConfigEintrag[]): string[] {
|
||||||
|
return eintraege.flatMap((roh) => {
|
||||||
|
const e = sshConfigPruefen(roh);
|
||||||
|
return [
|
||||||
|
`Host ${e.alias}`,
|
||||||
|
` HostName ${e.hostName}`,
|
||||||
|
` User ${e.benutzer}`,
|
||||||
|
...(e.port !== 22 ? [` Port ${e.port}`] : []),
|
||||||
|
` IdentityFile ${e.identityFile}`,
|
||||||
|
" IdentitiesOnly yes",
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** known_hosts-Zeile: `name`, bei abweichendem Port `[name]:port`. */
|
||||||
|
export function knownHostsZeile(
|
||||||
|
hostName: string,
|
||||||
|
port: number,
|
||||||
|
algorithmus: string,
|
||||||
|
publicKey: string,
|
||||||
|
): string {
|
||||||
|
if (!HOSTNAME.test(hostName)) throw new SshConfigFehler("Ungültiger Hostname für known_hosts.");
|
||||||
|
if (!/^[a-z0-9@.-]+$/.test(algorithmus) || !/^[A-Za-z0-9+/]+={0,2}$/.test(publicKey)) {
|
||||||
|
throw new SshConfigFehler("Ungültiger Host-Key.");
|
||||||
|
}
|
||||||
|
const name = port === 22 ? hostName : `[${hostName}]:${port}`;
|
||||||
|
return `${name} ${algorithmus} ${publicKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pfad eines Private Keys: nur direkt unter ~/.ssh (#017). Gleiche Regel wie im Worker
|
||||||
|
* (relativenPfadPruefen) – hier fuer die Eingabepruefung im Web.
|
||||||
|
*/
|
||||||
|
export function privatPfadPruefen(pfad: string): string {
|
||||||
|
const sauber = pfad.trim();
|
||||||
|
const relativ = sauber.startsWith("~/") ? sauber.slice(2) : sauber;
|
||||||
|
if (!/^\.ssh\/[A-Za-z0-9._@+-]+$/.test(relativ) || relativ.includes("..")) {
|
||||||
|
throw new SshConfigFehler("Der Pfad muss direkt unter ~/.ssh liegen, z. B. ~/.ssh/id_ed25519_backup.");
|
||||||
|
}
|
||||||
|
if (/^\.ssh\/(authorized_keys2?|config|known_hosts)$/.test(relativ)) {
|
||||||
|
throw new SshConfigFehler("Dieser Dateiname ist für andere Zwecke reserviert.");
|
||||||
|
}
|
||||||
|
return `~/${relativ}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
BLOCK_ANFANG,
|
||||||
|
BLOCK_ANFANG_PRAEFIX,
|
||||||
|
BLOCK_ENDE,
|
||||||
|
BLOCK_ENDE_PRAEFIX,
|
||||||
|
} from "@/lib/ssh/verwalteter-block";
|
||||||
|
import { zeilenDiff, type DiffZeile } from "@/lib/ssh/zeilen-diff";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwalteter Block in beliebigen Textdateien (#017): ~/.ssh/config und known_hosts.
|
||||||
|
* Dieselben Zusagen wie bei authorized_keys (#015): Rest bytegenau, beschaedigter Block wird
|
||||||
|
* nie automatisch ueberschrieben. Leerer Soll-Block entfernt den Block ganz.
|
||||||
|
*/
|
||||||
|
export class TextblockFehler extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "TextblockFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextblockErgebnis = {
|
||||||
|
readonly inhalt: string;
|
||||||
|
readonly geaendert: boolean;
|
||||||
|
readonly diff: DiffZeile[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function ohneCr(zeile: string): string {
|
||||||
|
return zeile.endsWith("\r") ? zeile.slice(0, -1) : zeile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function textblockSetzen(
|
||||||
|
inhalt: string,
|
||||||
|
zeilen: readonly string[],
|
||||||
|
position: "anfang" | "ende" = "ende",
|
||||||
|
): TextblockErgebnis {
|
||||||
|
if (zeilen.some((z) => /[\r\n\x00]/.test(z)))
|
||||||
|
throw new TextblockFehler("Ein Eintrag enthält einen Zeilenumbruch.");
|
||||||
|
const bom = inhalt.startsWith("");
|
||||||
|
const text = bom ? inhalt.slice(1) : inhalt;
|
||||||
|
const endetMitZeilenende = text.length === 0 || text.endsWith("\n");
|
||||||
|
const roh = text.split("\n");
|
||||||
|
if (endetMitZeilenende) roh.pop();
|
||||||
|
|
||||||
|
const anfaenge = roh.flatMap((z, i) => (ohneCr(z).trim().startsWith(BLOCK_ANFANG_PRAEFIX) ? [i] : []));
|
||||||
|
const enden = roh.flatMap((z, i) => (ohneCr(z).trim().startsWith(BLOCK_ENDE_PRAEFIX) ? [i] : []));
|
||||||
|
if (
|
||||||
|
anfaenge.length > 1 ||
|
||||||
|
enden.length > 1 ||
|
||||||
|
anfaenge.length !== enden.length ||
|
||||||
|
(anfaenge[0] ?? 0) > (enden[0] ?? 0)
|
||||||
|
) {
|
||||||
|
throw new TextblockFehler("Der verwaltete Block ist beschädigt. Bitte die Datei von Hand bereinigen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const crlf = roh.filter((z) => z.endsWith("\r")).length * 2 >= roh.length && roh.length > 0;
|
||||||
|
const block = zeilen.length ? [BLOCK_ANFANG, ...zeilen, BLOCK_ENDE].map((z) => (crlf ? `${z}\r` : z)) : [];
|
||||||
|
|
||||||
|
let neu: string[];
|
||||||
|
let mitZeilenende = endetMitZeilenende;
|
||||||
|
if (anfaenge.length === 1) {
|
||||||
|
neu = [...roh.slice(0, anfaenge[0]), ...block, ...roh.slice(enden[0]! + 1)];
|
||||||
|
} else if (block.length === 0) {
|
||||||
|
neu = roh;
|
||||||
|
} else if (position === "anfang") {
|
||||||
|
neu = [...block, ...roh];
|
||||||
|
mitZeilenende = true;
|
||||||
|
} else {
|
||||||
|
neu = [...roh, ...block];
|
||||||
|
mitZeilenende = true;
|
||||||
|
}
|
||||||
|
const ergebnis = (bom ? "" : "") + neu.join("\n") + (mitZeilenende && neu.length ? "\n" : "");
|
||||||
|
return {
|
||||||
|
inhalt: ergebnis,
|
||||||
|
geaendert: ergebnis !== inhalt,
|
||||||
|
diff: zeilenDiff(roh.map(ohneCr), neu.map(ohneCr)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Zeilen-Diff fuer den Dry-Run (#015). Klassisches LCS – die Dateien sind klein
|
||||||
|
* (authorized_keys, ~/.ssh/config), und eine ehrliche Vorschau ist wichtiger als Tempo.
|
||||||
|
*/
|
||||||
|
export type DiffZeile = { readonly art: "hinzu" | "entfernt" | "kontext"; readonly text: string };
|
||||||
|
|
||||||
|
/** Ab dieser Groesse kein LCS mehr, sondern „alles alt raus, alles neu rein“. */
|
||||||
|
const HOECHSTENS_ZEILEN = 4000;
|
||||||
|
|
||||||
|
export function zeilenDiff(alt: readonly string[], neu: readonly string[]): DiffZeile[] {
|
||||||
|
if (alt.length > HOECHSTENS_ZEILEN || neu.length > HOECHSTENS_ZEILEN) {
|
||||||
|
return [
|
||||||
|
...alt.map((text) => ({ art: "entfernt" as const, text })),
|
||||||
|
...neu.map((text) => ({ art: "hinzu" as const, text })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const n = alt.length;
|
||||||
|
const m = neu.length;
|
||||||
|
// laenge[i][j] = LCS von alt[i..] und neu[j..]
|
||||||
|
const laenge: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
||||||
|
for (let i = n - 1; i >= 0; i -= 1) {
|
||||||
|
for (let j = m - 1; j >= 0; j -= 1) {
|
||||||
|
laenge[i]![j] =
|
||||||
|
alt[i] === neu[j] ? laenge[i + 1]![j + 1]! + 1 : Math.max(laenge[i + 1]![j]!, laenge[i]![j + 1]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ergebnis: DiffZeile[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < n && j < m) {
|
||||||
|
if (alt[i] === neu[j]) {
|
||||||
|
ergebnis.push({ art: "kontext", text: alt[i]! });
|
||||||
|
i += 1;
|
||||||
|
j += 1;
|
||||||
|
} else if (laenge[i + 1]![j]! >= laenge[i]![j + 1]!) {
|
||||||
|
ergebnis.push({ art: "entfernt", text: alt[i]! });
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
ergebnis.push({ art: "hinzu", text: neu[j]! });
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < n) ergebnis.push({ art: "entfernt", text: alt[i++]! });
|
||||||
|
while (j < m) ergebnis.push({ art: "hinzu", text: neu[j++]! });
|
||||||
|
return ergebnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kuerzt lange unveraenderte Strecken auf je `rand` Zeilen davor und danach – eine
|
||||||
|
* authorized_keys mit 200 fremden Zeilen soll im Dry-Run nicht die Aenderung verstecken.
|
||||||
|
*/
|
||||||
|
export function diffVerdichten(zeilen: readonly DiffZeile[], rand = 2): DiffZeile[] {
|
||||||
|
const wichtig = zeilen.map((z) => z.art !== "kontext");
|
||||||
|
const behalten = zeilen.map((_, index) => {
|
||||||
|
for (let abstand = -rand; abstand <= rand; abstand += 1) {
|
||||||
|
if (wichtig[index + abstand]) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const ergebnis: DiffZeile[] = [];
|
||||||
|
let ausgelassen = 0;
|
||||||
|
zeilen.forEach((zeile, index) => {
|
||||||
|
if (behalten[index]) {
|
||||||
|
if (ausgelassen > 0)
|
||||||
|
ergebnis.push({ art: "kontext", text: `… ${ausgelassen} unveränderte Zeile(n) …` });
|
||||||
|
ausgelassen = 0;
|
||||||
|
ergebnis.push(zeile);
|
||||||
|
} else {
|
||||||
|
ausgelassen += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (ausgelassen > 0) ergebnis.push({ art: "kontext", text: `… ${ausgelassen} unveränderte Zeile(n) …` });
|
||||||
|
return ergebnis;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { BENUTZERNAME_MUSTER, shellQuote } from "@/lib/shell";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zielbenutzer anlegen und sudoers pflegen (#018) – reine Funktionen fuer Vorschau und Skript.
|
||||||
|
*
|
||||||
|
* Die sudoers-Datei zeigt die Oberflaeche **vor** dem Anlegen genau so, wie sie geschrieben
|
||||||
|
* wird (Vorgabe: „Inhalt vorher anzeigen und mit visudo -c pruefen“). Auf dem Host wird sie
|
||||||
|
* unter einem Namen mit Punkt geprueft – den ignoriert sudo – und erst danach aktiviert.
|
||||||
|
*/
|
||||||
|
export type SudoModus = "KEINER" | "BEFEHLE" | "VOLL";
|
||||||
|
|
||||||
|
export class ZielkontoFehler extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "ZielkontoFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kopfzeile, an der sshkv seine eigenen sudoers-Dateien erkennt. */
|
||||||
|
export const SUDOERS_KOPF = "# Verwaltet von SSHKeyVault (sshkv) - nicht manuell bearbeiten";
|
||||||
|
|
||||||
|
/** Benutzer, die sshkv nie anlegt, aendert oder loescht. */
|
||||||
|
const GESCHUETZT = new Set(["root", "sshkv", "daemon", "bin", "sys", "nobody", "sshd", "www-data"]);
|
||||||
|
|
||||||
|
export function benutzerPruefen(benutzer: string): void {
|
||||||
|
if (!BENUTZERNAME_MUSTER.test(benutzer)) {
|
||||||
|
throw new ZielkontoFehler(
|
||||||
|
"Ungültiger Benutzername (Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function istGeschuetzt(benutzer: string): boolean {
|
||||||
|
return GESCHUETZT.has(benutzer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sudoersDatei(benutzer: string): string {
|
||||||
|
benutzerPruefen(benutzer);
|
||||||
|
return `/etc/sudoers.d/sshkv-${benutzer}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft einen erlaubten Befehl. Absoluter Pfad, Argumente erlaubt; die Zeichen, die in
|
||||||
|
* sudoers eine Bedeutung haben (`, : = \`) und Wildcards sind ausgeschlossen – eine Liste,
|
||||||
|
* die anders wirkt als sie aussieht, waere schlimmer als gar keine.
|
||||||
|
*/
|
||||||
|
export function befehlPruefen(befehl: string): string {
|
||||||
|
const sauber = befehl.trim().replace(/\s+/g, " ");
|
||||||
|
if (!sauber.startsWith("/"))
|
||||||
|
throw new ZielkontoFehler(`„${sauber}“: Befehle brauchen einen absoluten Pfad.`);
|
||||||
|
if (/[,:=\\*?[\]!"'`$;&|<>(){}\x00-\x1f\x7f]/.test(sauber)) {
|
||||||
|
throw new ZielkontoFehler(`„${sauber}“ enthält Zeichen, die in sudoers eine Sonderbedeutung haben.`);
|
||||||
|
}
|
||||||
|
if (sauber.length > 200) throw new ZielkontoFehler("Ein Befehl ist zu lang.");
|
||||||
|
return sauber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inhalt der sudoers-Datei oder `null` (Modus KEINER). */
|
||||||
|
export function sudoersInhalt(
|
||||||
|
benutzer: string,
|
||||||
|
modus: SudoModus,
|
||||||
|
befehle: readonly string[] = [],
|
||||||
|
): string | null {
|
||||||
|
benutzerPruefen(benutzer);
|
||||||
|
if (modus === "KEINER") return null;
|
||||||
|
if (modus === "VOLL") return `${SUDOERS_KOPF}\n${benutzer} ALL=(ALL:ALL) NOPASSWD: ALL\n`;
|
||||||
|
const geprueft = befehle.map(befehlPruefen).filter(Boolean);
|
||||||
|
if (geprueft.length === 0) throw new ZielkontoFehler("Bitte mindestens einen erlaubten Befehl angeben.");
|
||||||
|
return `${SUDOERS_KOPF}\n${benutzer} ALL=(root) NOPASSWD: ${geprueft.join(", ")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MARKE_ANGELEGT = "SSHKV-BENUTZER-ANGELEGT";
|
||||||
|
export const MARKE_VORHANDEN = "SSHKV-BENUTZER-VORHANDEN";
|
||||||
|
export const MARKE_FREMD = "SSHKV-SUDOERS-FREMD";
|
||||||
|
export const MARKE_OK = "SSHKV-ZIELKONTO-OK";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skript (als root): Benutzer anlegen, falls er fehlt, ~/.ssh vorbereiten und sudoers setzen
|
||||||
|
* oder entfernen. Eine vorhandene sudoers-Datei ohne sshkv-Kopf wird nie ueberschrieben.
|
||||||
|
*/
|
||||||
|
export function zielkontoSkript(benutzer: string, modus: SudoModus, befehle: readonly string[] = []): string {
|
||||||
|
benutzerPruefen(benutzer);
|
||||||
|
if (istGeschuetzt(benutzer))
|
||||||
|
throw new ZielkontoFehler(`„${benutzer}“ verwaltet sshkv nicht als Zielkonto.`);
|
||||||
|
const inhalt = sudoersInhalt(benutzer, modus, befehle);
|
||||||
|
const datei = sudoersDatei(benutzer);
|
||||||
|
return `set -eu
|
||||||
|
umask 077
|
||||||
|
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
B=${shellQuote(benutzer)}
|
||||||
|
DATEI=${shellQuote(datei)}
|
||||||
|
if id -u "$B" >/dev/null 2>&1; then
|
||||||
|
echo ${MARKE_VORHANDEN}
|
||||||
|
else
|
||||||
|
SHELL_PFAD=/bin/sh; [ -x /bin/bash ] && SHELL_PFAD=/bin/bash
|
||||||
|
useradd --create-home --shell "$SHELL_PFAD" --comment SSHKeyVault "$B"
|
||||||
|
echo ${MARKE_ANGELEGT}
|
||||||
|
fi
|
||||||
|
H=$(getent passwd "$B" | cut -d: -f6)
|
||||||
|
runuser -u "$B" -- mkdir -p "$H/.ssh"
|
||||||
|
runuser -u "$B" -- chmod 0700 "$H/.ssh"
|
||||||
|
if [ -e "$DATEI" ] && ! head -n 1 "$DATEI" | grep -qxF ${shellQuote(SUDOERS_KOPF)}; then
|
||||||
|
echo ${MARKE_FREMD}; exit 5
|
||||||
|
fi
|
||||||
|
${
|
||||||
|
inhalt === null
|
||||||
|
? 'rm -f "$DATEI"'
|
||||||
|
: `NEU=$(mktemp /etc/sudoers.d/.sshkv.XXXXXX)
|
||||||
|
printf '%s' ${shellQuote(inhalt)} > "$NEU"
|
||||||
|
chmod 0440 "$NEU"
|
||||||
|
if ! visudo -cf "$NEU" >/dev/null; then rm -f "$NEU"; echo "sudoers-Prüfung fehlgeschlagen." >&2; exit 6; fi
|
||||||
|
mv "$NEU" "$DATEI"`
|
||||||
|
}
|
||||||
|
echo ${MARKE_OK}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skript (als root) zum Entfernen beim Widerruf (#024): sudoers-Datei (nur die eigene) und –
|
||||||
|
* nur wenn sshkv den Benutzer angelegt hat – den Benutzer samt Home.
|
||||||
|
*/
|
||||||
|
export function zielkontoEntfernenSkript(benutzer: string, benutzerLoeschen: boolean): string {
|
||||||
|
benutzerPruefen(benutzer);
|
||||||
|
if (istGeschuetzt(benutzer))
|
||||||
|
throw new ZielkontoFehler(`„${benutzer}“ verwaltet sshkv nicht als Zielkonto.`);
|
||||||
|
return `set -eu
|
||||||
|
PATH=/usr/sbin:/usr/bin:/sbin:/bin
|
||||||
|
B=${shellQuote(benutzer)}
|
||||||
|
DATEI=${shellQuote(sudoersDatei(benutzer))}
|
||||||
|
if [ -e "$DATEI" ] && head -n 1 "$DATEI" | grep -qxF ${shellQuote(SUDOERS_KOPF)}; then rm -f "$DATEI"; fi
|
||||||
|
${benutzerLoeschen ? 'if id -u "$B" >/dev/null 2>&1; then pkill -u "$B" 2>/dev/null || true; userdel -r "$B" 2>/dev/null || userdel "$B"; fi' : ""}
|
||||||
|
echo ${MARKE_OK}
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||||
|
import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen";
|
||||||
|
import { befehlPruefen, ZielkontoFehler } from "@/lib/ssh/zielkonto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorlagen (#019): Platzhalter ersetzen und Vorlagen pruefen – rein, damit Assistent,
|
||||||
|
* Formular und Tests dieselben Regeln haben.
|
||||||
|
*
|
||||||
|
* Platzhalter: {{quellHost}}, {{quellHostIp}}, {{zielHost}}, {{zielHostIp}}. Ein unbekannter
|
||||||
|
* Platzhalter ist ein Fehler, kein leerer Text – sonst stuende `from=""` in authorized_keys.
|
||||||
|
*/
|
||||||
|
export const PLATZHALTER = ["quellHost", "quellHostIp", "zielHost", "zielHostIp"] as const;
|
||||||
|
export type Platzhalter = (typeof PLATZHALTER)[number];
|
||||||
|
export type PlatzhalterWerte = Partial<Record<Platzhalter, string>>;
|
||||||
|
|
||||||
|
export class VorlagenFehler extends Error {
|
||||||
|
constructor(
|
||||||
|
nachricht: string,
|
||||||
|
readonly feld?: string,
|
||||||
|
) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "VorlagenFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MUSTER = /\{\{\s*([A-Za-z]+)\s*\}\}/g;
|
||||||
|
|
||||||
|
export function platzhalterIn(text: string): string[] {
|
||||||
|
return [...text.matchAll(MUSTER)].map((treffer) => treffer[1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function platzhalterErsetzen(text: string, werte: PlatzhalterWerte): string {
|
||||||
|
return text.replace(MUSTER, (_, name: string) => {
|
||||||
|
if (!(PLATZHALTER as readonly string[]).includes(name)) {
|
||||||
|
throw new VorlagenFehler(`Unbekannter Platzhalter {{${name}}}.`);
|
||||||
|
}
|
||||||
|
const wert = werte[name as Platzhalter];
|
||||||
|
if (!wert) throw new VorlagenFehler(`Für {{${name}}} fehlt ein Wert.`);
|
||||||
|
if (/["\\\s,]/.test(wert))
|
||||||
|
throw new VorlagenFehler(`Der Wert für {{${name}}} enthält unzulässige Zeichen.`);
|
||||||
|
return wert;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine IPv4/IPv6-Adresse oder ein Netz in CIDR-Schreibweise, wie sie in from="…" stehen darf. */
|
||||||
|
export function istIpAdresse(wert: string): boolean {
|
||||||
|
return (
|
||||||
|
/^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/.test(wert) ||
|
||||||
|
(/^[0-9a-f:]+(\/\d{1,3})?$/i.test(wert) && wert.includes(":"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VorlageEingabe = {
|
||||||
|
readonly name: string;
|
||||||
|
readonly beschreibung?: string | null;
|
||||||
|
readonly schluesselTyp: "ED25519" | "RSA4096";
|
||||||
|
readonly kategorie: "PERSON" | "DIENST";
|
||||||
|
readonly standardLaufzeitTage?: number | null;
|
||||||
|
readonly zielbenutzer: string;
|
||||||
|
readonly zielbenutzerAnlegen: boolean;
|
||||||
|
readonly sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||||
|
readonly sudoBefehle: readonly string[];
|
||||||
|
readonly authorizedKeysOptionen?: string | null;
|
||||||
|
readonly sshConfigAlias?: string | null;
|
||||||
|
readonly knownHostsEintragen: boolean;
|
||||||
|
readonly privatNachAusrollenLoeschen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Beispielwerte, mit denen eine Vorlage beim Speichern probeweise aufgeloest wird. */
|
||||||
|
const PROBE: PlatzhalterWerte = {
|
||||||
|
quellHost: "srv-quelle",
|
||||||
|
quellHostIp: "10.0.0.1",
|
||||||
|
zielHost: "srv-ziel",
|
||||||
|
zielHostIp: "10.0.0.2",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Prueft eine Vorlage so, wie sie spaeter benutzt wird: mit aufgeloesten Platzhaltern. */
|
||||||
|
export function vorlagePruefen(eingabe: VorlageEingabe): VorlageEingabe {
|
||||||
|
if (!eingabe.name.trim() || eingabe.name.length > 80)
|
||||||
|
throw new VorlagenFehler("Bitte einen Namen angeben.", "name");
|
||||||
|
if (!BENUTZERNAME_MUSTER.test(eingabe.zielbenutzer)) {
|
||||||
|
throw new VorlagenFehler("Ungültiger Zielbenutzer.", "zielbenutzer");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
eingabe.standardLaufzeitTage != null &&
|
||||||
|
(eingabe.standardLaufzeitTage < 1 || eingabe.standardLaufzeitTage > 3650)
|
||||||
|
) {
|
||||||
|
throw new VorlagenFehler("Laufzeit zwischen 1 und 3650 Tagen.", "standardLaufzeitTage");
|
||||||
|
}
|
||||||
|
if (eingabe.kategorie === "PERSON" && (eingabe.zielbenutzerAnlegen || eingabe.sudoModus !== "KEINER")) {
|
||||||
|
throw new VorlagenFehler(
|
||||||
|
"Personen-Vorlagen legen keine Benutzer an und vergeben kein sudo.",
|
||||||
|
"kategorie",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!eingabe.zielbenutzerAnlegen && eingabe.sudoModus !== "KEINER") {
|
||||||
|
throw new VorlagenFehler(
|
||||||
|
"sudo-Rechte vergibt sshkv nur für Benutzer, die es selbst anlegt.",
|
||||||
|
"sudoModus",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (eingabe.sudoModus === "BEFEHLE") {
|
||||||
|
if (eingabe.sudoBefehle.length === 0)
|
||||||
|
throw new VorlagenFehler("Bitte mindestens einen Befehl angeben.", "sudoBefehle");
|
||||||
|
eingabe.sudoBefehle.forEach(befehlPruefen);
|
||||||
|
}
|
||||||
|
if (eingabe.authorizedKeysOptionen)
|
||||||
|
optionenPruefen(platzhalterErsetzen(eingabe.authorizedKeysOptionen, PROBE));
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof ZielkontoFehler) throw new VorlagenFehler(fehler.message, "sudoBefehle");
|
||||||
|
if (fehler instanceof OptionenFehler || fehler instanceof VorlagenFehler) {
|
||||||
|
throw new VorlagenFehler(
|
||||||
|
fehler.message,
|
||||||
|
fehler instanceof VorlagenFehler && fehler.feld ? fehler.feld : "authorizedKeysOptionen",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
if (eingabe.sshConfigAlias) {
|
||||||
|
let alias: string;
|
||||||
|
try {
|
||||||
|
alias = platzhalterErsetzen(eingabe.sshConfigAlias, PROBE);
|
||||||
|
} catch (fehler) {
|
||||||
|
throw new VorlagenFehler((fehler as Error).message, "sshConfigAlias");
|
||||||
|
}
|
||||||
|
if (!/^[A-Za-z0-9._-]{1,64}$/.test(alias))
|
||||||
|
throw new VorlagenFehler("Ungültiger Alias.", "sshConfigAlias");
|
||||||
|
}
|
||||||
|
return eingabe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AufgeloesteVorlage = {
|
||||||
|
readonly zielbenutzer: string;
|
||||||
|
readonly authorizedKeysOptionen: string | null;
|
||||||
|
readonly sshConfigAlias: string | null;
|
||||||
|
readonly ablaufAm: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Loest eine Vorlage fuer einen konkreten Assistenten-Lauf auf (#020). */
|
||||||
|
export function vorlageAufloesen(
|
||||||
|
vorlage: Pick<
|
||||||
|
VorlageEingabe,
|
||||||
|
"zielbenutzer" | "authorizedKeysOptionen" | "sshConfigAlias" | "standardLaufzeitTage"
|
||||||
|
>,
|
||||||
|
werte: PlatzhalterWerte,
|
||||||
|
jetzt = new Date(),
|
||||||
|
): AufgeloesteVorlage {
|
||||||
|
const optionen = vorlage.authorizedKeysOptionen
|
||||||
|
? optionenPruefen(platzhalterErsetzen(vorlage.authorizedKeysOptionen, werte)) || null
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
zielbenutzer: vorlage.zielbenutzer,
|
||||||
|
authorizedKeysOptionen: optionen,
|
||||||
|
sshConfigAlias: vorlage.sshConfigAlias ? platzhalterErsetzen(vorlage.sshConfigAlias, werte) : null,
|
||||||
|
ablaufAm: vorlage.standardLaufzeitTage
|
||||||
|
? new Date(jetzt.getTime() + vorlage.standardLaufzeitTage * 24 * 60 * 60 * 1000)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { eigenenPublicKeyHinterlegen, eigenenSchluesselEntfernen } from "@/server/services/meine-schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-Service (#013). Recht `eigeneSchluesselHinterlegen` hat jede Rolle; der Service
|
||||||
|
* arbeitet ausschliesslich mit der Person des angemeldeten Kontos.
|
||||||
|
*/
|
||||||
|
export type HinterlegenZustand = FormularZustand & { readonly erfolg?: string };
|
||||||
|
|
||||||
|
export async function eigenenKeyHinterlegenAktion(
|
||||||
|
_vorher: HinterlegenZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<HinterlegenZustand> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("eigeneSchluesselHinterlegen");
|
||||||
|
const neu = await eigenenPublicKeyHinterlegen(benutzer, {
|
||||||
|
name: textfeld(formular, "name") ?? "",
|
||||||
|
publicKey: textfeld(formular, "publicKey") ?? "",
|
||||||
|
});
|
||||||
|
revalidatePath("/meine-schluessel");
|
||||||
|
return { erfolg: `Hinterlegt: ${neu.fingerprint}` };
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eigenenKeyEntfernenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("eigeneSchluesselHinterlegen");
|
||||||
|
await eigenenSchluesselEntfernen(benutzer, textfeld(formular, "id") ?? "");
|
||||||
|
revalidatePath("/meine-schluessel");
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import type { PersonEingabe } from "@/lib/personen";
|
||||||
|
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { personAnlegen, personArchivieren, personBearbeiten } from "@/server/services/personen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formular-Aktionen fuer Personen (#013). Personen pflegt, wer Schluessel verwaltet (Admin,
|
||||||
|
* Operator) – eine Person ist die Klammer um Personen-Keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function personFelder(formular: FormData): PersonEingabe {
|
||||||
|
return {
|
||||||
|
name: textfeld(formular, "name") ?? "",
|
||||||
|
email: textfeld(formular, "email"),
|
||||||
|
organisation: textfeld(formular, "organisation"),
|
||||||
|
notiz: textfeld(formular, "notiz"),
|
||||||
|
benutzerId: textfeld(formular, "benutzerId"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function personAnlegenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
let id: string;
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||||
|
({ id } = await personAnlegen(personFelder(formular), akteurVon(benutzer)));
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/personen");
|
||||||
|
redirect(`/personen/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function personBearbeitenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
const id = textfeld(formular, "id") ?? "";
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||||
|
await personBearbeiten(id, personFelder(formular), akteurVon(benutzer));
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/personen", "layout");
|
||||||
|
redirect(`/personen/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function personArchivierenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
const id = textfeld(formular, "id") ?? "";
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||||
|
await personArchivieren(id, akteurVon(benutzer));
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/personen", "layout");
|
||||||
|
redirect("/personen");
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
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 {
|
||||||
|
abholungAnfordern,
|
||||||
|
privateKeyAbholen,
|
||||||
|
privateKeyLoeschenAnfordern,
|
||||||
|
} from "@/server/services/privatekey";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private Key anzeigen und loeschen (#014). Jede Aktion prueft selbst Recht **und** frische
|
||||||
|
* Bestaetigung (`rechtErforderlich` mit einem Recht aus BESTAETIGUNG_NOETIG). Fehlt die
|
||||||
|
* Bestaetigung, meldet die Antwort `bestaetigen: true`, und die Oberflaeche leitet nach
|
||||||
|
* /bestaetigen weiter.
|
||||||
|
*
|
||||||
|
* `privateKeyAnzeigenAktion` ist die einzige Stelle, an der ein Private Key als Antwort an den
|
||||||
|
* Browser geht – genau einmal, fuer die Anzeige im Dialog.
|
||||||
|
*/
|
||||||
|
export type PrivateKeyAntwort = {
|
||||||
|
readonly vorgangId?: string;
|
||||||
|
readonly fehler?: string;
|
||||||
|
readonly bestaetigen?: boolean;
|
||||||
|
readonly privateKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = z.string().min(1).max(64);
|
||||||
|
const zweck = z.enum(["anzeigen", "herunterladen"]);
|
||||||
|
|
||||||
|
function fehlerAntwort(fehler: unknown): PrivateKeyAntwort {
|
||||||
|
if (fehler instanceof BestaetigungFehler) return { fehler: fehler.message, bestaetigen: true };
|
||||||
|
if (fehler instanceof RechteFehler || fehler instanceof HostFehler) return { fehler: fehler.message };
|
||||||
|
if (fehler instanceof z.ZodError) return { fehler: "Ungültige Anfrage." };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function abholungAnfordernAktion(schluesselId: string, art: string): Promise<PrivateKeyAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
|
||||||
|
return {
|
||||||
|
vorgangId: await abholungAnfordern(id.parse(schluesselId), zweck.parse(art), akteurVon(benutzer)),
|
||||||
|
};
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function privateKeyAnzeigenAktion(vorgangId: string): Promise<PrivateKeyAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("privateKeyAnzeigen");
|
||||||
|
const { privateKey } = await privateKeyAbholen(id.parse(vorgangId), "anzeigen", akteurVon(benutzer));
|
||||||
|
return { privateKey };
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function privateKeyLoeschenAktion(schluesselId: string): Promise<PrivateKeyAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("privateKeyLoeschen");
|
||||||
|
const vorgangId = await privateKeyLoeschenAnfordern(id.parse(schluesselId), akteurVon(benutzer));
|
||||||
|
revalidatePath(`/schluessel/${schluesselId}`);
|
||||||
|
return { vorgangId };
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import {
|
||||||
|
erzeugenAnfordern,
|
||||||
|
paarImportAnfordern,
|
||||||
|
publicKeyImportieren,
|
||||||
|
schluesselBearbeiten,
|
||||||
|
} from "@/server/services/schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktionen fuer Schluessel (#012). Duenn: Recht pruefen, Felder lesen, Service rufen.
|
||||||
|
*
|
||||||
|
* Passphrase und Private Key verlassen diese Funktionen nur verschluesselt im Job (der
|
||||||
|
* Service verschluesselt sie) – nie in einer Antwort, einem Log oder dem Protokoll.
|
||||||
|
*/
|
||||||
|
export type SchluesselAntwort = FormularZustand & {
|
||||||
|
/** Vorgang im Worker (Erzeugen, Paar-Import) – die Oberflaeche verfolgt ihn. */
|
||||||
|
readonly vorgangId?: string;
|
||||||
|
/** Sofort angelegt (Public-Key-Import). */
|
||||||
|
readonly schluesselId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function metadaten(formular: FormData) {
|
||||||
|
return {
|
||||||
|
name: textfeld(formular, "name") ?? "",
|
||||||
|
slug: textfeld(formular, "slug"),
|
||||||
|
kommentar: textfeld(formular, "kommentar"),
|
||||||
|
tags: textfeld(formular, "tags") ?? "",
|
||||||
|
ablaufAm: textfeld(formular, "ablaufAm"),
|
||||||
|
personId: textfeld(formular, "personId"),
|
||||||
|
vorlageId: textfeld(formular, "vorlageId"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Passphrase mit Wiederholung – die Wiederholung prueft der Server, nicht nur das Formular. */
|
||||||
|
function passphraseLesen(formular: FormData): { passphrase?: string; fehler?: SchluesselAntwort } {
|
||||||
|
const passphrase = textfeld(formular, "passphrase");
|
||||||
|
const wiederholung = textfeld(formular, "passphraseWiederholung");
|
||||||
|
if (passphrase && passphrase !== wiederholung) {
|
||||||
|
return {
|
||||||
|
fehler: {
|
||||||
|
fehler: "Die Passphrasen stimmen nicht überein.",
|
||||||
|
feldfehler: { passphraseWiederholung: "Stimmt nicht mit der Passphrase überein." },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return passphrase ? { passphrase } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function schluesselErzeugenAktion(
|
||||||
|
_vorher: SchluesselAntwort,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<SchluesselAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||||
|
const { passphrase, fehler } = passphraseLesen(formular);
|
||||||
|
if (fehler) return fehler;
|
||||||
|
const vorgangId = await erzeugenAnfordern(
|
||||||
|
{ ...metadaten(formular), typ: textfeld(formular, "typ") as "ED25519" | "RSA4096" | undefined },
|
||||||
|
passphrase,
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
return { vorgangId };
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function paarImportierenAktion(
|
||||||
|
_vorher: SchluesselAntwort,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<SchluesselAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||||
|
const vorgangId = await paarImportAnfordern(
|
||||||
|
metadaten(formular),
|
||||||
|
textfeld(formular, "privateKey"),
|
||||||
|
// Beim Import nur zur Pruefung – keine Wiederholung noetig, falsche merkt der Worker.
|
||||||
|
textfeld(formular, "passphrase"),
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
return { vorgangId };
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publicKeyImportierenAktion(
|
||||||
|
_vorher: SchluesselAntwort,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<SchluesselAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||||
|
const schluessel = await publicKeyImportieren(
|
||||||
|
{
|
||||||
|
...metadaten(formular),
|
||||||
|
kategorie: textfeld(formular, "kategorie") as "PERSON" | "DIENST" | undefined,
|
||||||
|
publicKey: textfeld(formular, "publicKey") ?? "",
|
||||||
|
},
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
revalidatePath("/schluessel");
|
||||||
|
return { schluesselId: schluessel.id };
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function schluesselBearbeitenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
const id = textfeld(formular, "id") ?? "";
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("schluesselVerwalten");
|
||||||
|
await schluesselBearbeiten(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
name: textfeld(formular, "name") ?? "",
|
||||||
|
tags: textfeld(formular, "tags") ?? "",
|
||||||
|
ablaufAm: textfeld(formular, "ablaufAm"),
|
||||||
|
personId: textfeld(formular, "personId"),
|
||||||
|
vorlageId: textfeld(formular, "vorlageId"),
|
||||||
|
status: textfeld(formular, "status") as "AKTIV" | "ABGELAUFEN" | undefined,
|
||||||
|
},
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/schluessel", "layout");
|
||||||
|
redirect(`/schluessel/${id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { akteurVon, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
|
import {
|
||||||
|
VerwendungsFehler,
|
||||||
|
ausrollenStarten,
|
||||||
|
privateVerwendungAnlegen,
|
||||||
|
privateVerwendungEntfernen,
|
||||||
|
publicVerwendungAnlegen,
|
||||||
|
publicVerwendungEntfernen,
|
||||||
|
trockenlaufStarten,
|
||||||
|
} from "@/server/services/verwendungen";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
|
||||||
|
|
||||||
|
export type VorgangsAntwort = { readonly vorgangId?: string; readonly fehler?: string };
|
||||||
|
|
||||||
|
function alsAntwort(fehler: unknown): { fehler: string } {
|
||||||
|
if (
|
||||||
|
fehler instanceof VerwendungsFehler ||
|
||||||
|
fehler instanceof RechteFehler ||
|
||||||
|
fehler instanceof BestaetigungFehler
|
||||||
|
) {
|
||||||
|
return { fehler: fehler.message };
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publicZuweisenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||||
|
const schluesselId = z.string().min(1).max(64).parse(textfeld(formular, "schluesselId"));
|
||||||
|
// „host:<id>“ oder „gruppe:<id>“ aus einer einzigen Auswahlliste.
|
||||||
|
const ziel = textfeld(formular, "ziel") ?? "";
|
||||||
|
const [art, id] = ziel.split(":");
|
||||||
|
if (!id || (art !== "host" && art !== "gruppe"))
|
||||||
|
return { feldfehler: { ziel: "Bitte ein Ziel wählen." } };
|
||||||
|
await publicVerwendungAnlegen(
|
||||||
|
{
|
||||||
|
schluesselId,
|
||||||
|
hostId: art === "host" ? id : null,
|
||||||
|
hostgruppeId: art === "gruppe" ? id : null,
|
||||||
|
zielbenutzer: (textfeld(formular, "zielbenutzer") ?? "").trim(),
|
||||||
|
optionen: textfeld(formular, "optionen") ?? null,
|
||||||
|
},
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
revalidatePath(`/schluessel/${schluesselId}`);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publicEntfernenAktion(
|
||||||
|
verwendungId: string,
|
||||||
|
schluesselId: string,
|
||||||
|
): Promise<{ fehler?: string }> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||||
|
await publicVerwendungEntfernen(z.string().min(1).max(64).parse(verwendungId), akteurVon(benutzer));
|
||||||
|
revalidatePath(`/schluessel/${schluesselId}`);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AusrollAuswahl = { readonly schluesselId: string } | { readonly hostId: string };
|
||||||
|
|
||||||
|
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||||
|
const id = z.string().min(1).max(64);
|
||||||
|
return {
|
||||||
|
vorgangId: await trockenlaufStarten(
|
||||||
|
"schluesselId" in auswahl
|
||||||
|
? { schluesselId: id.parse(auswahl.schluesselId) }
|
||||||
|
: { 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"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ausrollenAktion(trockenlaufId: string): Promise<VorgangsAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||||
|
return {
|
||||||
|
vorgangId: await ausrollenStarten(z.string().min(1).max(64).parse(trockenlaufId), akteurVon(benutzer)),
|
||||||
|
};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Private Key einem Quell-Host zuweisen (#017) – nur Admins. */
|
||||||
|
export async function privateZuweisenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||||
|
const schluesselId = z.string().min(1).max(64).parse(textfeld(formular, "schluesselId"));
|
||||||
|
const alias = (textfeld(formular, "alias") ?? "").trim();
|
||||||
|
const zielHostId = textfeld(formular, "zielHostId");
|
||||||
|
await privateVerwendungAnlegen(
|
||||||
|
{
|
||||||
|
schluesselId,
|
||||||
|
hostId: textfeld(formular, "hostId") ?? "",
|
||||||
|
zielbenutzer: (textfeld(formular, "zielbenutzer") ?? "").trim(),
|
||||||
|
pfad: textfeld(formular, "pfad") ?? "",
|
||||||
|
sshConfig: alias
|
||||||
|
? {
|
||||||
|
alias,
|
||||||
|
hostName: (textfeld(formular, "hostName") ?? "").trim(),
|
||||||
|
benutzer: (textfeld(formular, "sshBenutzer") ?? "").trim(),
|
||||||
|
port: Number(textfeld(formular, "port") ?? "22"),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
knownHostsFuer: zielHostId ? [zielHostId] : [],
|
||||||
|
beimWiderruf: textfeld(formular, "beimWiderruf") === "UMBENENNEN" ? "UMBENENNEN" : "LOESCHEN",
|
||||||
|
},
|
||||||
|
akteurVon(benutzer),
|
||||||
|
);
|
||||||
|
revalidatePath(`/schluessel/${schluesselId}`);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function privateEntfernenAktion(
|
||||||
|
verwendungId: string,
|
||||||
|
schluesselId: string,
|
||||||
|
): Promise<{ fehler?: string }> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
|
||||||
|
await privateVerwendungEntfernen(z.string().min(1).max(64).parse(verwendungId), akteurVon(benutzer));
|
||||||
|
revalidatePath(`/schluessel/${schluesselId}`);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verbindungstest A → B (#020). Harmlos, aber nur fuer Rollen, die Hosts verwalten. */
|
||||||
|
export async function verbindungTestenAktion(privatVerwendungId: string): Promise<VorgangsAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("hostsVerwalten");
|
||||||
|
return {
|
||||||
|
vorgangId: await vorgangEinreihen({
|
||||||
|
art: "VERBINDUNGSTEST",
|
||||||
|
angefordertVonId: benutzer.id,
|
||||||
|
eingabe: { privatVerwendungId: z.string().min(1).max(64).parse(privatVerwendungId) },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { VorlagenFehler, type VorlageEingabe } from "@/lib/vorlagen";
|
||||||
|
import { akteurVon, fehlerZustand, textfeld, type FormularZustand } from "@/server/actions/formular";
|
||||||
|
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { vorlageAnlegen, vorlageArchivieren, vorlageBearbeiten } from "@/server/services/vorlagen";
|
||||||
|
|
||||||
|
/** Vorlagen-Aktionen (#019): nur Admins (Recht einstellungenVerwalten). */
|
||||||
|
|
||||||
|
function felder(formular: FormData): VorlageEingabe {
|
||||||
|
const tage = textfeld(formular, "standardLaufzeitTage");
|
||||||
|
return {
|
||||||
|
name: textfeld(formular, "name") ?? "",
|
||||||
|
beschreibung: textfeld(formular, "beschreibung") ?? null,
|
||||||
|
schluesselTyp: textfeld(formular, "schluesselTyp") === "RSA4096" ? "RSA4096" : "ED25519",
|
||||||
|
kategorie: textfeld(formular, "kategorie") === "PERSON" ? "PERSON" : "DIENST",
|
||||||
|
standardLaufzeitTage: tage ? Number(tage) : null,
|
||||||
|
zielbenutzer: (textfeld(formular, "zielbenutzer") ?? "").trim(),
|
||||||
|
zielbenutzerAnlegen: formular.get("zielbenutzerAnlegen") === "ja",
|
||||||
|
sudoModus:
|
||||||
|
(["KEINER", "BEFEHLE", "VOLL"] as const).find((m) => m === textfeld(formular, "sudoModus")) ?? "KEINER",
|
||||||
|
sudoBefehle: (textfeld(formular, "sudoBefehle") ?? "")
|
||||||
|
.split("\n")
|
||||||
|
.map((b) => b.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
authorizedKeysOptionen: textfeld(formular, "authorizedKeysOptionen") ?? null,
|
||||||
|
sshConfigAlias: textfeld(formular, "sshConfigAlias") ?? null,
|
||||||
|
knownHostsEintragen: formular.get("knownHostsEintragen") === "ja",
|
||||||
|
privatNachAusrollenLoeschen: formular.get("privatNachAusrollenLoeschen") === "ja",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function alsZustand(fehler: unknown): FormularZustand {
|
||||||
|
if (fehler instanceof VorlagenFehler) {
|
||||||
|
return {
|
||||||
|
fehler: fehler.message,
|
||||||
|
...(fehler.feld ? { feldfehler: { [fehler.feld]: fehler.message } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vorlageSpeichernAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
const id = textfeld(formular, "id");
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("einstellungenVerwalten");
|
||||||
|
if (id) await vorlageBearbeiten(id, felder(formular), akteurVon(benutzer));
|
||||||
|
else await vorlageAnlegen(felder(formular), akteurVon(benutzer));
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/vorlagen");
|
||||||
|
redirect("/vorlagen");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vorlageArchivierenAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("einstellungenVerwalten");
|
||||||
|
await vorlageArchivieren(textfeld(formular, "id") ?? "", akteurVon(benutzer));
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/vorlagen");
|
||||||
|
redirect("/vorlagen");
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export type Recht =
|
|||||||
| "publicKeyAusrollen"
|
| "publicKeyAusrollen"
|
||||||
| "privateKeyAusrollen"
|
| "privateKeyAusrollen"
|
||||||
| "privateKeyAnzeigen"
|
| "privateKeyAnzeigen"
|
||||||
|
| "privateKeyLoeschen"
|
||||||
| "deploymentKeyVerwalten"
|
| "deploymentKeyVerwalten"
|
||||||
| "massenwiderruf"
|
| "massenwiderruf"
|
||||||
| "zielkontenVerwalten"
|
| "zielkontenVerwalten"
|
||||||
@@ -33,6 +34,8 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
|||||||
// Zugriff hat, kann ihn faktisch lesen – deshalb nur Admins.
|
// Zugriff hat, kann ihn faktisch lesen – deshalb nur Admins.
|
||||||
privateKeyAusrollen: ["ADMIN"],
|
privateKeyAusrollen: ["ADMIN"],
|
||||||
privateKeyAnzeigen: ["ADMIN"],
|
privateKeyAnzeigen: ["ADMIN"],
|
||||||
|
// Endgueltig und nicht rueckgaengig zu machen (#014) – wie Anzeigen nur mit Re-Auth.
|
||||||
|
privateKeyLoeschen: ["ADMIN"],
|
||||||
deploymentKeyVerwalten: ["ADMIN"],
|
deploymentKeyVerwalten: ["ADMIN"],
|
||||||
massenwiderruf: ["ADMIN"],
|
massenwiderruf: ["ADMIN"],
|
||||||
zielkontenVerwalten: ["ADMIN"],
|
zielkontenVerwalten: ["ADMIN"],
|
||||||
@@ -42,6 +45,7 @@ export const RECHTE: Readonly<Record<Recht, readonly Rolle[]>> = {
|
|||||||
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
|
/** Rechte, die zusaetzlich eine frische Bestaetigung (Re-Auth) verlangen. */
|
||||||
export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
|
export const BESTAETIGUNG_NOETIG: ReadonlySet<Recht> = new Set<Recht>([
|
||||||
"privateKeyAnzeigen",
|
"privateKeyAnzeigen",
|
||||||
|
"privateKeyLoeschen",
|
||||||
"deploymentKeyVerwalten",
|
"deploymentKeyVerwalten",
|
||||||
"massenwiderruf",
|
"massenwiderruf",
|
||||||
"zielkontenVerwalten",
|
"zielkontenVerwalten",
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodelle fuer Personen (#013). Jede angemeldete Rolle darf sie sehen; Schluessel nur mit
|
||||||
|
* festem `select` ohne Chiffrat-Felder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PersonFilter = { readonly q?: string; readonly archivierte?: boolean };
|
||||||
|
|
||||||
|
export async function personenListe(filter: PersonFilter = {}) {
|
||||||
|
const q = filter.q?.trim().slice(0, 100);
|
||||||
|
const where: Prisma.PersonWhereInput = {
|
||||||
|
...(filter.archivierte ? {} : { archiviertAm: null }),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q, mode: "insensitive" } },
|
||||||
|
{ email: { contains: q, mode: "insensitive" } },
|
||||||
|
{ organisation: { contains: q, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const personen = await prisma.person.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
organisation: true,
|
||||||
|
archiviertAm: true,
|
||||||
|
benutzer: { select: { id: true, name: true } },
|
||||||
|
_count: { select: { schluessel: { where: { status: "AKTIV" } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return personen.map(({ _count, ...rest }) => ({ ...rest, aktiveSchluessel: _count.schluessel }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonListenEintrag = Awaited<ReturnType<typeof personenListe>>[number];
|
||||||
|
|
||||||
|
export async function personDetail(id: string) {
|
||||||
|
const person = await prisma.person.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
organisation: true,
|
||||||
|
notiz: true,
|
||||||
|
archiviertAm: true,
|
||||||
|
angelegtAm: true,
|
||||||
|
benutzerId: true,
|
||||||
|
benutzer: { select: { id: true, name: true, email: true, rolle: true } },
|
||||||
|
schluessel: {
|
||||||
|
orderBy: [{ status: "asc" }, { name: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
typ: true,
|
||||||
|
kategorie: true,
|
||||||
|
status: true,
|
||||||
|
publicKey: true,
|
||||||
|
fingerprint: true,
|
||||||
|
ablaufAm: true,
|
||||||
|
selbstHinterlegt: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||||
|
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!person) return null;
|
||||||
|
return {
|
||||||
|
...person,
|
||||||
|
schluessel: person.schluessel.map(({ _count, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonDetail = NonNullable<Awaited<ReturnType<typeof personDetail>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konten, die sich mit einer Person verknuepfen lassen: noch keiner Person zugeordnet, oder
|
||||||
|
* der Person `personId` selbst. Der Notfall-Admin ist kein Mensch mit eigenen Keys.
|
||||||
|
*/
|
||||||
|
export async function kontenAuswahl(personId?: string) {
|
||||||
|
return prisma.benutzer.findMany({
|
||||||
|
where: {
|
||||||
|
istNotfall: false,
|
||||||
|
OR: [{ person: null }, ...(personId ? [{ person: { id: personId } }] : [])],
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
|
||||||
|
*
|
||||||
|
* Feste `select`-Listen ohne Chiffrat-Felder (CLAUDE.md, „Feste Regeln“). Ob ein Private Key
|
||||||
|
* gespeichert ist, verraet `masterKeyVersion`: der DB-CHECK `schluessel_umschlag_vollstaendig`
|
||||||
|
* setzt sie genau dann, wenn auch ein Chiffrat da ist – so muss das Chiffrat nie gelesen
|
||||||
|
* werden, nicht einmal fuer ein „ist null?“.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SCHLUESSEL_LISTE_SELECT = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
typ: true,
|
||||||
|
kategorie: true,
|
||||||
|
status: true,
|
||||||
|
publicKey: true,
|
||||||
|
fingerprint: true,
|
||||||
|
ablaufAm: true,
|
||||||
|
masterKeyVersion: true,
|
||||||
|
passphraseGeschuetzt: true,
|
||||||
|
person: { select: { id: true, name: true } },
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||||
|
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.SchluesselSelect;
|
||||||
|
|
||||||
|
export const SCHLUESSEL_DETAIL_SELECT = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
slug: true,
|
||||||
|
typ: true,
|
||||||
|
kategorie: true,
|
||||||
|
status: true,
|
||||||
|
publicKey: true,
|
||||||
|
fingerprint: true,
|
||||||
|
kommentar: true,
|
||||||
|
masterKeyVersion: true,
|
||||||
|
privatGeloeschtAm: true,
|
||||||
|
passphraseGeschuetzt: true,
|
||||||
|
instanzStandard: true,
|
||||||
|
selbstHinterlegt: true,
|
||||||
|
tags: true,
|
||||||
|
ablaufAm: true,
|
||||||
|
zuletztVerwendetAm: true,
|
||||||
|
angelegtAm: true,
|
||||||
|
personId: true,
|
||||||
|
vorlageId: true,
|
||||||
|
person: { select: { id: true, name: true } },
|
||||||
|
vorlage: { select: { id: true, name: true } },
|
||||||
|
erstelltVon: { select: { name: true } },
|
||||||
|
publicVerwendungen: {
|
||||||
|
where: { zustand: { not: "ENTFERNT" } },
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
optionen: true,
|
||||||
|
zustand: true,
|
||||||
|
zuletztAngewendetAm: true,
|
||||||
|
host: { select: { id: true, name: true } },
|
||||||
|
hostgruppe: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
privateVerwendungen: {
|
||||||
|
where: { zustand: { not: "ENTFERNT" } },
|
||||||
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
pfad: true,
|
||||||
|
sshConfig: true,
|
||||||
|
zustand: true,
|
||||||
|
zuletztAngewendetAm: true,
|
||||||
|
host: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.SchluesselSelect;
|
||||||
|
|
||||||
|
export type SchluesselFilter = {
|
||||||
|
readonly q?: string;
|
||||||
|
readonly kategorie?: string;
|
||||||
|
readonly status?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KATEGORIEN: readonly SchluesselKategorie[] = ["PERSON", "DIENST", "DEPLOYMENT"];
|
||||||
|
const STATI: readonly SchluesselStatus[] = ["AKTIV", "ROTIERT", "WIDERRUFEN", "ABGELAUFEN"];
|
||||||
|
|
||||||
|
function alsKategorie(wert: string | undefined): SchluesselKategorie | undefined {
|
||||||
|
return KATEGORIEN.find((k) => k === wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
|
||||||
|
return STATI.find((s) => s === wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 } : {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q, mode: "insensitive" } },
|
||||||
|
{ slug: { contains: q.toLowerCase() } },
|
||||||
|
{ fingerprint: { contains: q } },
|
||||||
|
{ kommentar: { contains: q, mode: "insensitive" } },
|
||||||
|
{ tags: { has: q.toLowerCase() } },
|
||||||
|
{ person: { name: { contains: q, mode: "insensitive" } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
const zeilen = await prisma.schluessel.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ name: "asc" }],
|
||||||
|
select: SCHLUESSEL_LISTE_SELECT,
|
||||||
|
});
|
||||||
|
return zeilen.map(({ _count, masterKeyVersion, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
privatGespeichert: masterKeyVersion !== null,
|
||||||
|
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>>[number];
|
||||||
|
|
||||||
|
export async function schluesselDetail(id: string) {
|
||||||
|
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
|
||||||
|
if (!schluessel) return null;
|
||||||
|
const { masterKeyVersion, ...rest } = schluessel;
|
||||||
|
return { ...rest, privatGespeichert: masterKeyVersion !== null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SchluesselDetail = NonNullable<Awaited<ReturnType<typeof schluesselDetail>>>;
|
||||||
|
|
||||||
|
/** Nur Slug und Public Key – fuer den Download `<slug>.pub`. */
|
||||||
|
export async function publicKeyExport(id: string) {
|
||||||
|
return prisma.schluessel.findUnique({ where: { id }, select: { slug: true, publicKey: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Auswahllisten
|
||||||
|
|
||||||
|
export async function personenAuswahl() {
|
||||||
|
return prisma.person.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, organisation: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vorlagenAuswahl() {
|
||||||
|
return prisma.vorlage.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, standardLaufzeitTage: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
|
/** Auswahl fuer „Public Key ausrollen auf …“ (#016): Hosts mit Bootstrap und Hostgruppen. */
|
||||||
|
export async function zielAuswahl() {
|
||||||
|
const [hosts, gruppen] = await Promise.all([
|
||||||
|
prisma.host.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
select: { id: true, name: true, bootstrapAm: true, adresse: true, port: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.hostgruppe.findMany({
|
||||||
|
select: { id: true, name: true, _count: { select: { mitglieder: true } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
hosts: hosts.map((h) => ({ id: h.id, name: h.name, bereit: h.bootstrapAm !== null })),
|
||||||
|
hostsDetails: hosts.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port })),
|
||||||
|
gruppen: gruppen.map((g) => ({ id: g.id, name: g.name, mitglieder: g._count.mitglieder })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZielAuswahl = Awaited<ReturnType<typeof zielAuswahl>>;
|
||||||
|
|
||||||
|
/** Was sshkv auf einem Host verwaltet (#016): Public Keys direkt und ueber Gruppen. */
|
||||||
|
export async function verwendungenAufHost(hostId: string) {
|
||||||
|
const [oeffentlich, privat] = await Promise.all([
|
||||||
|
prisma.publicVerwendung.findMany({
|
||||||
|
where: {
|
||||||
|
zustand: { not: "ENTFERNT" },
|
||||||
|
OR: [{ hostId }, { hostgruppe: { mitglieder: { some: { hostId } } } }],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
optionen: true,
|
||||||
|
zustand: true,
|
||||||
|
hostgruppe: { select: { name: true } },
|
||||||
|
schluessel: { select: { id: true, name: true, typ: true, fingerprint: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ zielbenutzer: "asc" }, { angelegtAm: "asc" }],
|
||||||
|
}),
|
||||||
|
prisma.privateVerwendung.findMany({
|
||||||
|
where: { hostId, zustand: { not: "ENTFERNT" } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
pfad: true,
|
||||||
|
zustand: true,
|
||||||
|
schluessel: { select: { id: true, name: true, typ: true, fingerprint: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ zielbenutzer: "asc" }, { pfad: "asc" }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { oeffentlich, privat };
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ export async function oidcBenutzerUebernehmen(anspruecke: OidcAnspruecke): Promi
|
|||||||
const daten = {
|
const daten = {
|
||||||
oidcSub: anspruecke.sub,
|
oidcSub: anspruecke.sub,
|
||||||
email: anspruecke.email,
|
email: anspruecke.email,
|
||||||
|
// Fuer die Verknuepfung im Self-Service (#013).
|
||||||
|
emailBestaetigt: anspruecke.emailBestaetigt,
|
||||||
name: anspruecke.name,
|
name: anspruecke.name,
|
||||||
rolle,
|
rolle,
|
||||||
letzteAnmeldungAm: new Date(),
|
letzteAnmeldungAm: new Date(),
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { HostFehler } from "@/server/services/hosts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fachlicher Fehler mit Meldung fuer die Oberflaeche und optional je Feld (#012, #013).
|
||||||
|
*
|
||||||
|
* Erbt von `HostFehler`, weil `fehlerZustand()` in src/server/actions/formular.ts genau den
|
||||||
|
* als „Meldung fuers Formular“ erkennt. So bleibt die eine Stelle, die Fehler in
|
||||||
|
* Formularzustaende uebersetzt, unveraendert.
|
||||||
|
*/
|
||||||
|
export class FachFehler extends HostFehler {
|
||||||
|
constructor(nachricht: string, feldfehler: Record<string, string> = {}) {
|
||||||
|
super(nachricht, feldfehler);
|
||||||
|
this.name = "FachFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import type { Benutzer } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { feldfehlerAus } from "@/lib/hosts";
|
||||||
|
import { eigenerKeySchema, type EigenerKeyEingabe } from "@/lib/personen";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import { istEindeutigkeitsFehler } from "@/server/services/hosts";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { publicKeyAnlegen } from "@/server/services/schluessel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-Service „Meine Schluessel“ (#013, Freigabe Frage 4): jeder angemeldete Benutzer
|
||||||
|
* hinterlegt eigene Public Keys. Wirksam werden sie erst, wenn ein Operator oder Admin sie
|
||||||
|
* einem Host zuweist – hier wird nichts ausgerollt.
|
||||||
|
*
|
||||||
|
* Jede Funktion nimmt den angemeldeten Benutzer und arbeitet nur mit **seiner** Person; eine
|
||||||
|
* Schluessel-ID von aussen wird immer gegen sie geprueft.
|
||||||
|
*/
|
||||||
|
type Konto = Pick<Benutzer, "id" | "name" | "email" | "emailBestaetigt">;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Person des Kontos und legt sie beim ersten Aufruf an. Verknuepft per
|
||||||
|
* `benutzerId`, sonst per E-Mail – aber nur, wenn der Anbieter die Adresse bestaetigt hat und
|
||||||
|
* genau eine freie, nicht archivierte Person dazu passt.
|
||||||
|
*/
|
||||||
|
export async function eigenePersonSicherstellen(konto: Konto): Promise<{ id: string; name: string }> {
|
||||||
|
const vorhanden = await prisma.person.findUnique({
|
||||||
|
where: { benutzerId: konto.id },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (vorhanden) return vorhanden;
|
||||||
|
|
||||||
|
const akteur = { id: konto.id, name: konto.name };
|
||||||
|
try {
|
||||||
|
if (konto.email && konto.emailBestaetigt) {
|
||||||
|
const passend = await prisma.person.findMany({
|
||||||
|
where: {
|
||||||
|
email: { equals: konto.email, mode: "insensitive" },
|
||||||
|
benutzerId: null,
|
||||||
|
archiviertAm: null,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
take: 2,
|
||||||
|
});
|
||||||
|
// Bedingt: hat inzwischen jemand anderes die Person verknuepft, aendert sich nichts,
|
||||||
|
// und das Konto bekommt unten eine eigene Person.
|
||||||
|
const verknuepft =
|
||||||
|
passend.length === 1
|
||||||
|
? await prisma.person.updateMany({
|
||||||
|
where: { id: passend[0]!.id, benutzerId: null },
|
||||||
|
data: { benutzerId: konto.id },
|
||||||
|
})
|
||||||
|
: { count: 0 };
|
||||||
|
if (verknuepft.count === 1) {
|
||||||
|
const person = await prisma.person.findUniqueOrThrow({
|
||||||
|
where: { benutzerId: konto.id },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "person.verknuepft",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { person: person.name, ueber: "email" },
|
||||||
|
});
|
||||||
|
return person;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const person = await prisma.person.create({
|
||||||
|
data: { name: konto.name, email: konto.email, benutzerId: konto.id },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "person.angelegt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { name: konto.name, ueber: "selbstauskunft" },
|
||||||
|
});
|
||||||
|
return person;
|
||||||
|
} catch (fehler) {
|
||||||
|
// Zwei gleichzeitige erste Aufrufe: der zweite findet die Person des ersten.
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) {
|
||||||
|
return prisma.person.findUniqueOrThrow({
|
||||||
|
where: { benutzerId: konto.id },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die eigenen Schluessel – mit genau den Feldern, die die Seite zeigt. */
|
||||||
|
export async function eigeneSchluessel(konto: Konto) {
|
||||||
|
const person = await eigenePersonSicherstellen(konto);
|
||||||
|
const schluessel = await prisma.schluessel.findMany({
|
||||||
|
where: { personId: person.id },
|
||||||
|
orderBy: { angelegtAm: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
typ: true,
|
||||||
|
status: true,
|
||||||
|
publicKey: true,
|
||||||
|
fingerprint: true,
|
||||||
|
kommentar: true,
|
||||||
|
selbstHinterlegt: true,
|
||||||
|
angelegtAm: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||||
|
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
person,
|
||||||
|
schluessel: schluessel.map(({ _count, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eigenenPublicKeyHinterlegen(konto: Konto, eingabe: EigenerKeyEingabe) {
|
||||||
|
const ergebnis = eigenerKeySchema.safeParse(eingabe);
|
||||||
|
if (!ergebnis.success) {
|
||||||
|
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
|
||||||
|
}
|
||||||
|
const person = await eigenePersonSicherstellen(konto);
|
||||||
|
return publicKeyAnlegen(
|
||||||
|
{
|
||||||
|
name: ergebnis.data.name,
|
||||||
|
slug: null,
|
||||||
|
kategorie: "PERSON",
|
||||||
|
publicKey: ergebnis.data.publicKey,
|
||||||
|
kommentar: null,
|
||||||
|
tags: [],
|
||||||
|
ablaufAm: null,
|
||||||
|
personId: person.id,
|
||||||
|
vorlageId: null,
|
||||||
|
selbstHinterlegt: true,
|
||||||
|
},
|
||||||
|
{ id: konto.id, name: konto.name },
|
||||||
|
"schluessel.selbst_hinterlegt",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt einen eigenen, selbst hinterlegten Key, solange er nirgends zugewiesen ist – auch
|
||||||
|
* keine entfernte Verwendung, damit der Verlauf am Key nicht verloren geht. Fremde Keys sind
|
||||||
|
* fuer diese Funktion „nicht vorhanden“, damit sie nichts ueber fremde IDs verraet.
|
||||||
|
*/
|
||||||
|
export async function eigenenSchluesselEntfernen(konto: Konto, schluesselId: string): Promise<void> {
|
||||||
|
const person = await eigenePersonSicherstellen(konto);
|
||||||
|
const schluessel = await prisma.schluessel.findFirst({
|
||||||
|
where: { id: schluesselId, personId: person.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
fingerprint: true,
|
||||||
|
selbstHinterlegt: true,
|
||||||
|
_count: { select: { publicVerwendungen: true, privateVerwendungen: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!schluessel) throw new FachFehler("Diesen Schlüssel gibt es unter Ihren Schlüsseln nicht.");
|
||||||
|
if (!schluessel.selbstHinterlegt) {
|
||||||
|
throw new FachFehler(
|
||||||
|
"Diesen Schlüssel hat ein Operator oder Admin angelegt – bitte dort entfernen lassen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (schluessel._count.publicVerwendungen + schluessel._count.privateVerwendungen > 0) {
|
||||||
|
throw new FachFehler(
|
||||||
|
"Der Schlüssel ist schon einem Host zugewiesen. Bitte einen Operator oder Admin bitten.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Bedingt geloescht: weist ein Operator den Key gerade zu, darf das Loeschen die neue
|
||||||
|
// Verwendung nicht per Cascade mitnehmen.
|
||||||
|
const geloescht = await tx.schluessel.deleteMany({
|
||||||
|
where: { id: schluessel.id, publicVerwendungen: { none: {} }, privateVerwendungen: { none: {} } },
|
||||||
|
});
|
||||||
|
if (geloescht.count !== 1) {
|
||||||
|
throw new FachFehler("Der Schlüssel wurde gerade zugewiesen und bleibt deshalb bestehen.");
|
||||||
|
}
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur: { id: konto.id, name: konto.name },
|
||||||
|
aktion: "schluessel.selbst_entfernt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: schluessel.fingerprint,
|
||||||
|
details: { name: schluessel.name },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { feldfehlerAus } from "@/lib/hosts";
|
||||||
|
import { personEingabeSchema, type PersonDaten, type PersonEingabe } from "@/lib/personen";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import { istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personen anlegen, bearbeiten, archivieren (#013).
|
||||||
|
*
|
||||||
|
* Archivieren statt Loeschen: an einer Person haengen Schluessel und Protokoll-Bezuege. Die
|
||||||
|
* Schluessel bleiben der archivierten Person zugeordnet – wer bis wann welchen Key hatte, soll
|
||||||
|
* nachvollziehbar bleiben. Widerrufen ist ein eigener Schritt (#024).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function pruefen(eingabe: PersonEingabe): PersonDaten {
|
||||||
|
const ergebnis = personEingabeSchema.safeParse(eingabe);
|
||||||
|
if (!ergebnis.success) {
|
||||||
|
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
|
||||||
|
}
|
||||||
|
return ergebnis.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function benutzerVergeben(): FachFehler {
|
||||||
|
return new FachFehler("Dieses Konto ist schon mit einer anderen Person verknüpft.", {
|
||||||
|
benutzerId: "Schon mit einer anderen Person verknüpft.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Das gewaehlte Konto muss existieren und frei sein (oder schon zu dieser Person gehoeren). */
|
||||||
|
async function benutzerPruefen(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
benutzerId: string | null,
|
||||||
|
personId?: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!benutzerId) return null;
|
||||||
|
const benutzer = await tx.benutzer.findUnique({
|
||||||
|
where: { id: benutzerId },
|
||||||
|
select: { name: true, person: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
if (!benutzer)
|
||||||
|
throw new FachFehler("Das gewählte Konto gibt es nicht.", { benutzerId: "Bitte neu wählen." });
|
||||||
|
if (benutzer.person && benutzer.person.id !== personId) throw benutzerVergeben();
|
||||||
|
return benutzer.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function personAnlegen(eingabe: PersonEingabe, akteur: Akteur) {
|
||||||
|
const daten = pruefen(eingabe);
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const konto = await benutzerPruefen(tx, daten.benutzerId);
|
||||||
|
const person = await tx.person.create({ data: daten, select: { id: true, name: true } });
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "person.angelegt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { name: daten.name, email: daten.email, organisation: daten.organisation, konto },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return person;
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) throw benutzerVergeben();
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function personBearbeiten(id: string, eingabe: PersonEingabe, akteur: Akteur) {
|
||||||
|
const daten = pruefen(eingabe);
|
||||||
|
const alt = await prisma.person.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
organisation: true,
|
||||||
|
notiz: true,
|
||||||
|
benutzerId: true,
|
||||||
|
archiviertAm: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!alt) throw new FachFehler("Diese Person gibt es nicht.");
|
||||||
|
if (alt.archiviertAm) throw new FachFehler("Archivierte Personen lassen sich nicht bearbeiten.");
|
||||||
|
|
||||||
|
const geaendert = Object.fromEntries(
|
||||||
|
(Object.keys(daten) as (keyof PersonDaten)[])
|
||||||
|
.filter((feld) => daten[feld] !== alt[feld])
|
||||||
|
// Die Notiz kann Persoenliches enthalten – im Protokoll steht nur, dass sie sich aenderte.
|
||||||
|
.map((feld) => [feld, feld === "notiz" ? "geändert" : { vorher: alt[feld], nachher: daten[feld] }]),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
await benutzerPruefen(tx, daten.benutzerId, id);
|
||||||
|
const person = await tx.person.update({ where: { id }, data: daten, select: { id: true, name: true } });
|
||||||
|
if (Object.keys(geaendert).length > 0) {
|
||||||
|
await protokollieren(
|
||||||
|
{ akteur, aktion: "person.geaendert", ergebnis: "ERFOLG", details: geaendert },
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return person;
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) throw benutzerVergeben();
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archiviert eine Person. Die Verknuepfung zum Konto faellt weg, damit der Self-Service beim
|
||||||
|
* naechsten Aufruf nicht wieder bei einer archivierten Person landet.
|
||||||
|
*/
|
||||||
|
export async function personArchivieren(id: string, akteur: Akteur) {
|
||||||
|
const person = await prisma.person.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { name: true, archiviertAm: true, _count: { select: { schluessel: true } } },
|
||||||
|
});
|
||||||
|
if (!person) throw new FachFehler("Diese Person gibt es nicht.");
|
||||||
|
if (person.archiviertAm) throw new FachFehler("Diese Person ist bereits archiviert.");
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const archiviert = await tx.person.update({
|
||||||
|
where: { id },
|
||||||
|
data: { archiviertAm: new Date(), benutzerId: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "person.archiviert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { name: person.name, schluessel: person._count.schluessel },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return archiviert;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { entschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { ABHOLFRIST_MS } from "@/lib/schluessel";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import type { Akteur } from "@/server/services/hosts";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private Key anzeigen, herunterladen, loeschen (#014). Die Rechte (`privateKeyAnzeigen`,
|
||||||
|
* `privateKeyLoeschen`, beide nur Admin mit frischer Bestaetigung) prueft die Action bzw. der
|
||||||
|
* Route Handler; hier stehen die fachlichen Regeln.
|
||||||
|
*
|
||||||
|
* Der Web-Prozess hat keinen Master-Key. Er bittet den Worker per Vorgang, den Key zu oeffnen;
|
||||||
|
* der legt ihn mit SSHKV_APP_SECRET verschluesselt in `Vorgang.ergebnis` ab, und
|
||||||
|
* `privateKeyAbholen` holt ihn genau einmal ab. Der Key steht nie im Protokoll, in einem Log
|
||||||
|
* oder einer Fehlermeldung – nur Fingerprint und Name.
|
||||||
|
*/
|
||||||
|
export type Zweck = "anzeigen" | "herunterladen";
|
||||||
|
|
||||||
|
/** Felder fuer die Pruefung – ob ein Key da ist, verraet `masterKeyVersion`, nie das Chiffrat. */
|
||||||
|
async function schluesselMitPrivateKey(schluesselId: string) {
|
||||||
|
const schluessel = await prisma.schluessel.findUnique({
|
||||||
|
where: { id: schluesselId },
|
||||||
|
select: { id: true, name: true, slug: true, kategorie: true, fingerprint: true, masterKeyVersion: true },
|
||||||
|
});
|
||||||
|
if (!schluessel) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
|
||||||
|
if (schluessel.masterKeyVersion === null) {
|
||||||
|
throw new FachFehler("Zu diesem Schlüssel ist kein Private Key gespeichert.");
|
||||||
|
}
|
||||||
|
// Der Deployment-Key ist root auf allen Hosts; er verlaesst sshkv nie. Im Notfall wird er
|
||||||
|
// rotiert, nicht exportiert.
|
||||||
|
if (schluessel.kategorie === "DEPLOYMENT") {
|
||||||
|
throw new FachFehler(
|
||||||
|
"Den Private Key eines Deployment-Keys gibt sshkv nicht heraus und löscht ihn nicht.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return schluessel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bittet den Worker, den Private Key zur einmaligen Abholung bereitzulegen. */
|
||||||
|
export async function abholungAnfordern(schluesselId: string, zweck: Zweck, akteur: Akteur): Promise<string> {
|
||||||
|
const schluessel = await schluesselMitPrivateKey(schluesselId);
|
||||||
|
const vorgangId = await vorgangEinreihen({
|
||||||
|
art: "PRIVATE_KEY_ABHOLEN",
|
||||||
|
angefordertVonId: akteur.id,
|
||||||
|
schluesselId: schluessel.id,
|
||||||
|
eingabe: { zweck },
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "privatekey.angefordert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: schluessel.fingerprint,
|
||||||
|
details: { name: schluessel.name, zweck },
|
||||||
|
});
|
||||||
|
return vorgangId;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Abholung = { ergebnis: unknown; beendetAm: Date | null; schluesselId: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt den bereitgelegten Private Key genau einmal ab: Lesen und Ueberschreiben geschehen in
|
||||||
|
* einer Anweisung unter Zeilensperre, ein zweiter Aufruf findet nichts mehr. Nur wer den
|
||||||
|
* Vorgang angefordert hat, kann abholen – und nur innerhalb der Abholfrist.
|
||||||
|
*/
|
||||||
|
export async function privateKeyAbholen(
|
||||||
|
vorgangId: string,
|
||||||
|
zweck: Zweck,
|
||||||
|
akteur: Akteur,
|
||||||
|
jetzt: Date = new Date(),
|
||||||
|
): Promise<{ privateKey: string; slug: string; name: string; fingerprint: string }> {
|
||||||
|
const [zeile] = await prisma.$queryRaw<Abholung[]>`
|
||||||
|
WITH alt AS (
|
||||||
|
SELECT "id", "ergebnis", "beendetAm", "schluesselId" FROM "Vorgang"
|
||||||
|
WHERE "id" = ${vorgangId}
|
||||||
|
AND "art" = 'PRIVATE_KEY_ABHOLEN'
|
||||||
|
AND "status" = 'ERLEDIGT'
|
||||||
|
AND "angefordertVonId" = ${akteur.id}
|
||||||
|
AND "ergebnis" ? 'abholung'
|
||||||
|
FOR UPDATE
|
||||||
|
)
|
||||||
|
UPDATE "Vorgang" v SET "ergebnis" = '{"abgeholt": true}'::jsonb
|
||||||
|
FROM alt WHERE v."id" = alt."id"
|
||||||
|
RETURNING alt."ergebnis", alt."beendetAm", alt."schluesselId"`;
|
||||||
|
|
||||||
|
if (!zeile || !zeile.schluesselId) {
|
||||||
|
throw new FachFehler("Nichts abzuholen – der Private Key wurde schon abgeholt oder ist verfallen.");
|
||||||
|
}
|
||||||
|
if (!zeile.beendetAm || jetzt.getTime() - zeile.beendetAm.getTime() > ABHOLFRIST_MS) {
|
||||||
|
throw new FachFehler("Die Abholung ist verfallen. Bitte den Private Key erneut anfordern.");
|
||||||
|
}
|
||||||
|
const chiffrat = (zeile.ergebnis as { abholung?: unknown }).abholung;
|
||||||
|
if (typeof chiffrat !== "string") throw new FachFehler("Die Abholung ist unvollständig.");
|
||||||
|
|
||||||
|
const schluessel = await prisma.schluessel.findUniqueOrThrow({
|
||||||
|
where: { id: zeile.schluesselId },
|
||||||
|
select: { name: true, slug: true, fingerprint: true },
|
||||||
|
});
|
||||||
|
const privateKey = entschluesseln(chiffrat);
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: zweck === "anzeigen" ? "privatekey.angezeigt" : "privatekey.heruntergeladen",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: schluessel.fingerprint,
|
||||||
|
details: { name: schluessel.name },
|
||||||
|
});
|
||||||
|
return { privateKey, ...schluessel };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fordert das endgueltige Loeschen des gespeicherten Private Keys an (#014, „Private Key aus
|
||||||
|
* SSHKeyVault loeschen“). Ausgefuehrt im Worker – nur src/worker/geheimnisse.ts fasst die
|
||||||
|
* Chiffrat-Felder an, auch zum Leeren.
|
||||||
|
*/
|
||||||
|
export async function privateKeyLoeschenAnfordern(schluesselId: string, akteur: Akteur): Promise<string> {
|
||||||
|
const schluessel = await schluesselMitPrivateKey(schluesselId);
|
||||||
|
return vorgangEinreihen({
|
||||||
|
art: "PRIVATE_KEY_LOESCHEN",
|
||||||
|
angefordertVonId: akteur.id,
|
||||||
|
schluesselId: schluessel.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { feldfehlerAus } from "@/lib/hosts";
|
||||||
|
import {
|
||||||
|
bearbeitenSchema,
|
||||||
|
erzeugenSchema,
|
||||||
|
freierSlug,
|
||||||
|
PASSPHRASE_MAX,
|
||||||
|
paarImportSchema,
|
||||||
|
PRIVATE_KEY_MAX,
|
||||||
|
publicImportSchema,
|
||||||
|
publicKeyZeileMitKommentar,
|
||||||
|
schluesselTypVon,
|
||||||
|
slugAus,
|
||||||
|
type BearbeitenEingabe,
|
||||||
|
type ErzeugenEingabe,
|
||||||
|
type PaarImportEingabe,
|
||||||
|
type PublicImportEingabe,
|
||||||
|
type SchluesselGeheimnis,
|
||||||
|
type SchluesselVorgangEingabe,
|
||||||
|
} from "@/lib/schluessel";
|
||||||
|
import { publicKeyZeileLesen, SchluesselFormatFehler } from "@/lib/ssh/openssh";
|
||||||
|
import type { z } from "@/lib/validierung";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import { istEindeutigkeitsFehler, type Akteur } from "@/server/services/hosts";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schluessel anlegen, importieren, bearbeiten (#012).
|
||||||
|
*
|
||||||
|
* Alles, was einen Private Key beruehrt (Erzeugen, Import eines Paars), wird hier nur
|
||||||
|
* geprueft und als Vorgang an den Worker gegeben – der Private Key reist mit
|
||||||
|
* SSHKV_APP_SECRET verschluesselt im Job, nie in `Vorgang.eingabe`. Ein reiner Public Key
|
||||||
|
* ist kein Geheimnis und wird direkt hier angelegt.
|
||||||
|
*
|
||||||
|
* Jede Prisma-Abfrage auf `Schluessel` hat ein festes `select` ohne Chiffrat-Felder
|
||||||
|
* (tests/schluessel-grenze.test.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Was die Oberflaeche nach dem Anlegen braucht – und nicht mehr. */
|
||||||
|
const ANGELEGT = { id: true, slug: true, fingerprint: true } satisfies Prisma.SchluesselSelect;
|
||||||
|
|
||||||
|
function pruefen<S extends z.ZodType>(schema: S, eingabe: unknown): z.output<S> {
|
||||||
|
const ergebnis = schema.safeParse(eingabe);
|
||||||
|
if (!ergebnis.success) {
|
||||||
|
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(ergebnis.error));
|
||||||
|
}
|
||||||
|
return ergebnis.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function slugFrei(slug: string, client: Prisma.TransactionClient = prisma): Promise<boolean> {
|
||||||
|
return (await client.schluessel.findUnique({ where: { slug }, select: { id: true } })) === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein ausdruecklich gewuenschter Slug muss frei sein; ein aus dem Namen abgeleiteter bekommt
|
||||||
|
* bei Bedarf eine Nummer angehaengt.
|
||||||
|
*/
|
||||||
|
export async function eindeutigerSlug(wunsch: string | null, name: string): Promise<string> {
|
||||||
|
if (wunsch) {
|
||||||
|
if (!(await slugFrei(wunsch))) {
|
||||||
|
throw new FachFehler(`Der Kurzname „${wunsch}“ ist schon vergeben.`, {
|
||||||
|
slug: "Dieser Kurzname ist schon vergeben.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return wunsch;
|
||||||
|
}
|
||||||
|
return freierSlug(slugAus(name), (kandidat) => slugFrei(kandidat));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt es diesen Fingerprint schon? Fuer eine Meldung mit Verweis auf den vorhandenen Key. */
|
||||||
|
export async function schluesselMitFingerprint(fingerprint: string) {
|
||||||
|
return prisma.schluessel.findUnique({
|
||||||
|
where: { fingerprint },
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bereitsVerwaltet(vorhanden: { id: string; name: string }): FachFehler {
|
||||||
|
return new FachFehler(
|
||||||
|
`Dieser Schlüssel wird bereits verwaltet: „${vorhanden.name}“ (/schluessel/${vorhanden.id}).`,
|
||||||
|
{ publicKey: "Dieser Schlüssel ist schon in sshkv." },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Heute 00:00 UTC – Vergleichsbasis fuer „Ablaufdatum liegt nicht in der Vergangenheit“. */
|
||||||
|
function heute(jetzt = new Date()): Date {
|
||||||
|
return new Date(Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), jetzt.getUTCDate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bezuege = { readonly personId: string | null; readonly vorlageId: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft Person und Vorlage und setzt ein fehlendes Ablaufdatum aus der Standardlaufzeit der
|
||||||
|
* Vorlage. Exportiert, weil der Worker dieselbe Pruefung beim Ausfuehren wiederholt.
|
||||||
|
*/
|
||||||
|
export async function bezuegePruefen(
|
||||||
|
daten: Bezuege & { readonly ablaufAm: Date | null },
|
||||||
|
client: Prisma.TransactionClient = prisma,
|
||||||
|
): Promise<{ ablaufAm: Date | null }> {
|
||||||
|
if (daten.personId) {
|
||||||
|
const person = await client.person.findUnique({
|
||||||
|
where: { id: daten.personId },
|
||||||
|
select: { archiviertAm: true },
|
||||||
|
});
|
||||||
|
if (!person || person.archiviertAm) {
|
||||||
|
throw new FachFehler("Die gewählte Person gibt es nicht (mehr).", { personId: "Bitte neu wählen." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ablaufAm = daten.ablaufAm;
|
||||||
|
if (daten.vorlageId) {
|
||||||
|
const vorlage = await client.vorlage.findUnique({
|
||||||
|
where: { id: daten.vorlageId },
|
||||||
|
select: { archiviertAm: true, standardLaufzeitTage: true },
|
||||||
|
});
|
||||||
|
if (!vorlage || vorlage.archiviertAm) {
|
||||||
|
throw new FachFehler("Die gewählte Vorlage gibt es nicht (mehr).", { vorlageId: "Bitte neu wählen." });
|
||||||
|
}
|
||||||
|
if (!ablaufAm && vorlage.standardLaufzeitTage) {
|
||||||
|
ablaufAm = new Date(heute().getTime() + vorlage.standardLaufzeitTage * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ablaufAm };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ablaufNichtVergangen(ablaufAm: Date | null): void {
|
||||||
|
if (ablaufAm && ablaufAm < heute()) {
|
||||||
|
throw new FachFehler("Das Ablaufdatum liegt in der Vergangenheit.", {
|
||||||
|
ablaufAm: "Bitte ein Datum ab heute wählen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function passphrasePruefen(passphrase: string | undefined): string | undefined {
|
||||||
|
if (!passphrase) return undefined;
|
||||||
|
if (passphrase.length > PASSPHRASE_MAX) {
|
||||||
|
throw new FachFehler("Die Passphrase ist zu lang.", {
|
||||||
|
passphrase: `Höchstens ${PASSPHRASE_MAX} Zeichen.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return passphrase;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Erzeugen und Paar-Import
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fordert ein neues Schluesselpaar an (#012). Liefert die Vorgangs-ID; die Oberflaeche
|
||||||
|
* verfolgt den Vorgang und springt nach Erfolg zum Schluessel.
|
||||||
|
*/
|
||||||
|
export async function erzeugenAnfordern(
|
||||||
|
eingabe: ErzeugenEingabe,
|
||||||
|
passphraseRoh: string | undefined,
|
||||||
|
akteur: Akteur,
|
||||||
|
): Promise<string> {
|
||||||
|
const daten = pruefen(erzeugenSchema, eingabe);
|
||||||
|
const passphrase = passphrasePruefen(passphraseRoh);
|
||||||
|
ablaufNichtVergangen(daten.ablaufAm);
|
||||||
|
const { ablaufAm } = await bezuegePruefen(daten);
|
||||||
|
const slug = await eindeutigerSlug(daten.slug, daten.name);
|
||||||
|
|
||||||
|
const vorgangsEingabe: SchluesselVorgangEingabe = {
|
||||||
|
name: daten.name,
|
||||||
|
slug,
|
||||||
|
tags: daten.tags,
|
||||||
|
ablaufAm: ablaufAm?.toISOString() ?? null,
|
||||||
|
personId: daten.personId,
|
||||||
|
vorlageId: daten.vorlageId,
|
||||||
|
kommentar: daten.kommentar,
|
||||||
|
typ: daten.typ,
|
||||||
|
mitPassphrase: Boolean(passphrase),
|
||||||
|
};
|
||||||
|
const geheimnis: SchluesselGeheimnis = passphrase ? { passphrase } : {};
|
||||||
|
return vorgangEinreihen({
|
||||||
|
art: "SCHLUESSEL_ERZEUGEN",
|
||||||
|
angefordertVonId: akteur.id,
|
||||||
|
eingabe: vorgangsEingabe,
|
||||||
|
...(passphrase ? { geheim: verschluesseln(JSON.stringify(geheimnis)) } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fordert den Import eines Paars an (#012). Der Private Key wird hier nur grob angesehen
|
||||||
|
* (Groesse, Kopfzeile) – lesen, pruefen und versiegeln tut der Worker. Eine Passphrase dient
|
||||||
|
* dort nur der Pruefung und wird verworfen; gespeichert wird der Key, wie er kam.
|
||||||
|
*/
|
||||||
|
export async function paarImportAnfordern(
|
||||||
|
eingabe: PaarImportEingabe,
|
||||||
|
privateKey: string | undefined,
|
||||||
|
passphraseRoh: string | undefined,
|
||||||
|
akteur: Akteur,
|
||||||
|
): Promise<string> {
|
||||||
|
const daten = pruefen(paarImportSchema, eingabe);
|
||||||
|
// Wie ssh-keygen ihn schreibt: LF-Zeilenenden und genau ein Zeilenumbruch am Ende.
|
||||||
|
const text = (privateKey ?? "").replace(/\r\n?/g, "\n").trim();
|
||||||
|
if (!text) {
|
||||||
|
throw new FachFehler("Bitte den Private Key einfügen.", {
|
||||||
|
privateKey: "Bitte den Private Key einfügen.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (text.length > PRIVATE_KEY_MAX || !text.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||||
|
// Feste Meldung – nie etwas aus dem eingefuegten Text zurueckgeben.
|
||||||
|
throw new FachFehler("Das ist kein Private Key im OpenSSH-Format.", {
|
||||||
|
privateKey: "Erwartet wird „-----BEGIN OPENSSH PRIVATE KEY-----“ …",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const passphrase = passphrasePruefen(passphraseRoh);
|
||||||
|
ablaufNichtVergangen(daten.ablaufAm);
|
||||||
|
const { ablaufAm } = await bezuegePruefen(daten);
|
||||||
|
const slug = await eindeutigerSlug(daten.slug, daten.name);
|
||||||
|
|
||||||
|
const vorgangsEingabe: SchluesselVorgangEingabe = {
|
||||||
|
name: daten.name,
|
||||||
|
slug,
|
||||||
|
tags: daten.tags,
|
||||||
|
ablaufAm: ablaufAm?.toISOString() ?? null,
|
||||||
|
personId: daten.personId,
|
||||||
|
vorlageId: daten.vorlageId,
|
||||||
|
kommentar: daten.kommentar,
|
||||||
|
mitPassphrase: Boolean(passphrase),
|
||||||
|
};
|
||||||
|
const geheimnis: SchluesselGeheimnis = { privateKey: `${text}\n`, ...(passphrase ? { passphrase } : {}) };
|
||||||
|
return vorgangEinreihen({
|
||||||
|
art: "SCHLUESSEL_IMPORTIEREN",
|
||||||
|
angefordertVonId: akteur.id,
|
||||||
|
eingabe: vorgangsEingabe,
|
||||||
|
geheim: verschluesseln(JSON.stringify(geheimnis)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Public-Key-Import
|
||||||
|
|
||||||
|
/** Liest eine Public-Key-Zeile und uebersetzt Formatfehler in eine Feldmeldung. */
|
||||||
|
export function publicKeyPruefen(zeile: string) {
|
||||||
|
try {
|
||||||
|
// Die RSA-Mindestlaenge gilt beim Import (Standard von publicKeyZeileLesen).
|
||||||
|
return publicKeyZeileLesen(zeile);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof SchluesselFormatFehler) {
|
||||||
|
throw new FachFehler(fehler.message, { publicKey: fehler.message });
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen Schluessel nur mit Public Key an (#012). Auch Grundlage des Self-Service
|
||||||
|
* (#013), der Kategorie, Person und `selbstHinterlegt` selbst setzt.
|
||||||
|
*/
|
||||||
|
export async function publicKeyAnlegen(
|
||||||
|
daten: {
|
||||||
|
readonly name: string;
|
||||||
|
readonly slug: string | null;
|
||||||
|
readonly kategorie: "PERSON" | "DIENST";
|
||||||
|
readonly publicKey: string;
|
||||||
|
readonly kommentar: string | null;
|
||||||
|
readonly tags: string[];
|
||||||
|
readonly ablaufAm: Date | null;
|
||||||
|
readonly personId: string | null;
|
||||||
|
readonly vorlageId: string | null;
|
||||||
|
readonly selbstHinterlegt?: boolean;
|
||||||
|
},
|
||||||
|
akteur: Akteur,
|
||||||
|
aktion = "schluessel.importiert",
|
||||||
|
) {
|
||||||
|
const info = publicKeyPruefen(daten.publicKey);
|
||||||
|
const vorhanden = await schluesselMitFingerprint(info.fingerprint);
|
||||||
|
if (vorhanden) throw bereitsVerwaltet(vorhanden);
|
||||||
|
ablaufNichtVergangen(daten.ablaufAm);
|
||||||
|
const { ablaufAm } = await bezuegePruefen(daten);
|
||||||
|
const slug = await eindeutigerSlug(daten.slug, daten.name);
|
||||||
|
const kommentar = daten.kommentar ?? (info.kommentar || `sshkv:${slug}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const schluessel = await tx.schluessel.create({
|
||||||
|
data: {
|
||||||
|
name: daten.name,
|
||||||
|
slug,
|
||||||
|
typ: schluesselTypVon(info.typ, info.bits),
|
||||||
|
kategorie: daten.kategorie,
|
||||||
|
publicKey: publicKeyZeileMitKommentar(daten.publicKey, kommentar),
|
||||||
|
fingerprint: info.fingerprint,
|
||||||
|
kommentar,
|
||||||
|
tags: daten.tags,
|
||||||
|
ablaufAm,
|
||||||
|
personId: daten.personId,
|
||||||
|
vorlageId: daten.vorlageId,
|
||||||
|
erstelltVonId: akteur.id,
|
||||||
|
selbstHinterlegt: daten.selbstHinterlegt ?? false,
|
||||||
|
},
|
||||||
|
select: ANGELEGT,
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion,
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: schluessel.fingerprint,
|
||||||
|
details: {
|
||||||
|
name: daten.name,
|
||||||
|
slug,
|
||||||
|
kategorie: daten.kategorie,
|
||||||
|
art: "public",
|
||||||
|
algorithmus: info.typ,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
return schluessel;
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
// Zwischen Pruefung und Insert kann derselbe Key oder Slug angelegt worden sein.
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) {
|
||||||
|
const jetzt = await schluesselMitFingerprint(info.fingerprint);
|
||||||
|
if (jetzt) throw bereitsVerwaltet(jetzt);
|
||||||
|
throw new FachFehler("Der Kurzname wurde gerade vergeben. Bitte erneut speichern.");
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publicKeyImportieren(eingabe: PublicImportEingabe, akteur: Akteur) {
|
||||||
|
const daten = pruefen(publicImportSchema, eingabe);
|
||||||
|
return publicKeyAnlegen(daten, akteur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Bearbeiten
|
||||||
|
|
||||||
|
export async function schluesselBearbeiten(id: string, eingabe: BearbeitenEingabe, akteur: Akteur) {
|
||||||
|
const daten = pruefen(bearbeitenSchema, eingabe);
|
||||||
|
const alt = await prisma.schluessel.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
kategorie: true,
|
||||||
|
status: true,
|
||||||
|
tags: true,
|
||||||
|
ablaufAm: true,
|
||||||
|
personId: true,
|
||||||
|
vorlageId: true,
|
||||||
|
fingerprint: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!alt) throw new FachFehler("Diesen Schlüssel gibt es nicht.");
|
||||||
|
if (daten.status && daten.status !== alt.status) {
|
||||||
|
if (alt.status !== "AKTIV" && alt.status !== "ABGELAUFEN") {
|
||||||
|
throw new FachFehler("Rotierte oder widerrufene Schlüssel lassen sich nicht wieder aktivieren.", {
|
||||||
|
status: "Status nicht änderbar.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Der Deployment-Key der Instanz wird nur ueber seine eigene Rotation ersetzt; ein
|
||||||
|
// „abgelaufener“ Deployment-Key liesse den Worker beim naechsten Start einen neuen erzeugen.
|
||||||
|
if (alt.kategorie === "DEPLOYMENT") {
|
||||||
|
throw new FachFehler("Der Status eines Deployment-Keys lässt sich hier nicht ändern.", {
|
||||||
|
status: "Status nicht änderbar.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (daten.ablaufAm?.getTime() !== alt.ablaufAm?.getTime()) ablaufNichtVergangen(daten.ablaufAm);
|
||||||
|
if (daten.personId !== alt.personId || daten.vorlageId !== alt.vorlageId) {
|
||||||
|
await bezuegePruefen({ personId: daten.personId, vorlageId: daten.vorlageId, ablaufAm: daten.ablaufAm });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nachher = {
|
||||||
|
name: daten.name,
|
||||||
|
tags: daten.tags,
|
||||||
|
ablaufAm: daten.ablaufAm,
|
||||||
|
personId: daten.personId,
|
||||||
|
vorlageId: daten.vorlageId,
|
||||||
|
status: daten.status ?? alt.status,
|
||||||
|
};
|
||||||
|
const geaendert = Object.fromEntries(
|
||||||
|
Object.entries(nachher)
|
||||||
|
.map(([feld, wert]) => [feld, { vorher: alt[feld as keyof typeof nachher], nachher: wert }] as const)
|
||||||
|
.filter(([, a]) => JSON.stringify(a.vorher) !== JSON.stringify(a.nachher)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const schluessel = await tx.schluessel.update({ where: { id }, data: nachher, select: ANGELEGT });
|
||||||
|
if (Object.keys(geaendert).length > 0) {
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "schluessel.geaendert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: alt.fingerprint,
|
||||||
|
details: geaendert,
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return schluessel;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import type { Host } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { SollEintrag } from "@/lib/ssh/authorized-keys";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Soll-Zustand einer authorized_keys (#016): welche Public Keys gehoeren in den
|
||||||
|
* verwalteten Block eines Benutzers auf einem Host – direkt zugewiesen oder ueber eine
|
||||||
|
* Hostgruppe. Laeuft im Worker und im Web (Vorschau), deshalb hier und nicht in src/worker.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Dienstbenutzer auf den Hosts (Freigabe Frage 1); gleiche Regel wie im Worker. */
|
||||||
|
export function anmeldebenutzerVon(host: Pick<Host, "anmeldeart">): string {
|
||||||
|
return host.anmeldeart === "ROOT" ? "root" : "sshkv";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zustaende, in denen eine Verwendung zum Soll gehoert. */
|
||||||
|
const IM_SOLL = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
|
||||||
|
|
||||||
|
export type Soll = {
|
||||||
|
readonly eintraege: SollEintrag[];
|
||||||
|
/** Fingerprints, die nie aus dem Block fallen duerfen (Deployment-Key). */
|
||||||
|
readonly pflicht: string[];
|
||||||
|
/** Verwendungen, die in diesem Soll stecken oder entfernt werden – fuer den Zustand danach. */
|
||||||
|
readonly verwendungIds: { readonly drin: string[]; readonly raus: string[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function deploymentKeyFuer(host: Pick<Host, "id" | "deploymentSchluesselId">) {
|
||||||
|
if (host.deploymentSchluesselId) {
|
||||||
|
return prisma.schluessel.findUnique({
|
||||||
|
where: { id: host.deploymentSchluesselId },
|
||||||
|
select: { publicKey: true, fingerprint: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const gruppe = await prisma.hostgruppeMitglied.findFirst({
|
||||||
|
where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } },
|
||||||
|
select: {
|
||||||
|
hostgruppe: { select: { deploymentSchluessel: { select: { publicKey: true, fingerprint: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (gruppe?.hostgruppe.deploymentSchluessel) return gruppe.hostgruppe.deploymentSchluessel;
|
||||||
|
return prisma.schluessel.findFirst({
|
||||||
|
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||||
|
select: { publicKey: true, fingerprint: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sollFuer(hostId: string, zielbenutzer: string): Promise<Soll> {
|
||||||
|
const host = await prisma.host.findUniqueOrThrow({
|
||||||
|
where: { id: hostId },
|
||||||
|
select: { id: true, anmeldeart: true, deploymentSchluesselId: true },
|
||||||
|
});
|
||||||
|
const verwendungen = await prisma.publicVerwendung.findMany({
|
||||||
|
where: {
|
||||||
|
zielbenutzer,
|
||||||
|
OR: [{ hostId }, { hostgruppe: { mitglieder: { some: { hostId } } } }],
|
||||||
|
zustand: { not: "ENTFERNT" },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
optionen: true,
|
||||||
|
zustand: true,
|
||||||
|
angelegtAm: true,
|
||||||
|
schluessel: { select: { publicKey: true, fingerprint: true, status: true } },
|
||||||
|
},
|
||||||
|
// Deterministische Reihenfolge im Block: aelteste Zuweisung zuerst.
|
||||||
|
orderBy: [{ angelegtAm: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const eintraege: SollEintrag[] = [];
|
||||||
|
const pflicht: string[] = [];
|
||||||
|
if (zielbenutzer === anmeldebenutzerVon(host)) {
|
||||||
|
const deployment = await deploymentKeyFuer(host);
|
||||||
|
if (deployment) {
|
||||||
|
eintraege.push({ publicKey: deployment.publicKey, fingerprint: deployment.fingerprint });
|
||||||
|
pflicht.push(deployment.fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const drin: string[] = [];
|
||||||
|
const raus: 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) {
|
||||||
|
eintraege.push({
|
||||||
|
publicKey: verwendung.schluessel.publicKey,
|
||||||
|
fingerprint: verwendung.schluessel.fingerprint,
|
||||||
|
optionen: verwendung.optionen,
|
||||||
|
});
|
||||||
|
drin.push(verwendung.id);
|
||||||
|
} else {
|
||||||
|
raus.push(verwendung.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { eintraege, pflicht, verwendungIds: { drin, raus } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Ziel des Ausrollens: authorized_keys (public) oder ~/.ssh-Dateien eines Quell-Hosts (privat). */
|
||||||
|
export type Ziel = {
|
||||||
|
readonly hostId: string;
|
||||||
|
readonly zielbenutzer: string;
|
||||||
|
readonly art?: "public" | "privat";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Alle (Host, Benutzer)-Paare, die eine Public-Key-Verwendung betrifft – Gruppen aufgeloest. */
|
||||||
|
export async function zieleVonVerwendungen(where: {
|
||||||
|
schluesselId?: string;
|
||||||
|
ids?: string[];
|
||||||
|
hostId?: string;
|
||||||
|
}): Promise<Ziel[]> {
|
||||||
|
const verwendungen = await prisma.publicVerwendung.findMany({
|
||||||
|
where: {
|
||||||
|
...(where.schluesselId ? { schluesselId: where.schluesselId } : {}),
|
||||||
|
...(where.ids ? { id: { in: where.ids } } : {}),
|
||||||
|
...(where.hostId
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ hostId: where.hostId },
|
||||||
|
{ hostgruppe: { mitglieder: { some: { hostId: where.hostId } } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
zustand: { not: "ENTFERNT" },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
zielbenutzer: true,
|
||||||
|
hostId: true,
|
||||||
|
hostgruppe: {
|
||||||
|
select: { mitglieder: { select: { hostId: true, host: { select: { archiviertAm: true } } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ziele = new Map<string, Ziel>();
|
||||||
|
for (const verwendung of verwendungen) {
|
||||||
|
const alleHostIds = verwendung.hostId
|
||||||
|
? [verwendung.hostId]
|
||||||
|
: (verwendung.hostgruppe?.mitglieder.filter((m) => !m.host.archiviertAm).map((m) => m.hostId) ?? []);
|
||||||
|
// Bei der Auswahl nach Host nur diesen Host, auch wenn die Verwendung an einer Gruppe haengt.
|
||||||
|
const hostIds = where.hostId ? alleHostIds.filter((id) => id === where.hostId) : alleHostIds;
|
||||||
|
for (const hostId of hostIds) {
|
||||||
|
ziele.set(`${hostId}\u0000${verwendung.zielbenutzer}`, {
|
||||||
|
hostId,
|
||||||
|
zielbenutzer: verwendung.zielbenutzer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...ziele.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ziele der Private-Key-Verwendungen (#017): je Quell-Host und Benutzer ein Ziel. */
|
||||||
|
export async function zieleVonPrivatVerwendungen(where: {
|
||||||
|
schluesselId?: string;
|
||||||
|
hostId?: string;
|
||||||
|
}): Promise<Ziel[]> {
|
||||||
|
const verwendungen = await prisma.privateVerwendung.findMany({
|
||||||
|
where: {
|
||||||
|
...(where.schluesselId ? { schluesselId: where.schluesselId } : {}),
|
||||||
|
...(where.hostId ? { hostId: where.hostId } : {}),
|
||||||
|
zustand: { not: "ENTFERNT" },
|
||||||
|
host: { archiviertAm: null },
|
||||||
|
},
|
||||||
|
select: { hostId: true, zielbenutzer: true },
|
||||||
|
});
|
||||||
|
const ziele = new Map<string, Ziel>();
|
||||||
|
for (const v of verwendungen) {
|
||||||
|
ziele.set(`${v.hostId}\u0000${v.zielbenutzer}`, {
|
||||||
|
hostId: v.hostId,
|
||||||
|
zielbenutzer: v.zielbenutzer,
|
||||||
|
art: "privat",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...ziele.values()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
|
||||||
|
import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen, type Ziel } from "@/server/services/soll";
|
||||||
|
import { SshConfigFehler, privatPfadPruefen, sshConfigPruefen } from "@/lib/ssh/ssh-config";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public-Key-Zuweisungen (#016): anlegen, zum Entfernen vormerken, Trockenlauf und
|
||||||
|
* Ausfuehren anstossen. Geschrieben wird nur im Worker.
|
||||||
|
*/
|
||||||
|
export class VerwendungsFehler extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "VerwendungsFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Akteur = { readonly id: string; readonly name: string };
|
||||||
|
|
||||||
|
export type PublicZuweisung = {
|
||||||
|
readonly schluesselId: string;
|
||||||
|
readonly hostId?: string | null;
|
||||||
|
readonly hostgruppeId?: string | null;
|
||||||
|
readonly zielbenutzer: string;
|
||||||
|
readonly optionen?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function publicVerwendungAnlegen(eingabe: PublicZuweisung, akteur: Akteur): Promise<string> {
|
||||||
|
if (!BENUTZERNAME_MUSTER.test(eingabe.zielbenutzer)) {
|
||||||
|
throw new VerwendungsFehler(
|
||||||
|
"Ungültiger Zielbenutzer (Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Boolean(eingabe.hostId) === Boolean(eingabe.hostgruppeId)) {
|
||||||
|
throw new VerwendungsFehler("Bitte genau einen Host oder eine Hostgruppe wählen.");
|
||||||
|
}
|
||||||
|
let optionen: string | null = null;
|
||||||
|
try {
|
||||||
|
optionen = optionenPruefen(eingabe.optionen ?? "") || null;
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof OptionenFehler) throw new VerwendungsFehler(fehler.message);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const schluessel = await prisma.schluessel.findUnique({
|
||||||
|
where: { id: eingabe.schluesselId },
|
||||||
|
select: { status: true, fingerprint: true, kategorie: 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.kategorie === "DEPLOYMENT") {
|
||||||
|
throw new VerwendungsFehler("Der Deployment-Key wird von sshkv selbst verwaltet.");
|
||||||
|
}
|
||||||
|
const ziel = eingabe.hostId
|
||||||
|
? await prisma.host.findUnique({
|
||||||
|
where: { id: eingabe.hostId },
|
||||||
|
select: { name: true, archiviertAm: true },
|
||||||
|
})
|
||||||
|
: await prisma.hostgruppe.findUnique({ where: { id: eingabe.hostgruppeId! }, select: { name: true } });
|
||||||
|
if (!ziel || ("archiviertAm" in ziel && ziel.archiviertAm))
|
||||||
|
throw new VerwendungsFehler("Das Ziel gibt es nicht (mehr).");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verwendung = await prisma.publicVerwendung.create({
|
||||||
|
data: {
|
||||||
|
schluesselId: eingabe.schluesselId,
|
||||||
|
hostId: eingabe.hostId ?? null,
|
||||||
|
hostgruppeId: eingabe.hostgruppeId ?? null,
|
||||||
|
zielbenutzer: eingabe.zielbenutzer,
|
||||||
|
optionen,
|
||||||
|
angelegtVonId: akteur.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "verwendung.public.angelegt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: eingabe.hostId ? ziel.name : null,
|
||||||
|
zielbenutzer: eingabe.zielbenutzer,
|
||||||
|
fingerprint: schluessel.fingerprint,
|
||||||
|
details: { hostgruppe: eingabe.hostgruppeId ? ziel.name : null, optionen },
|
||||||
|
});
|
||||||
|
return verwendung.id;
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
|
||||||
|
throw new VerwendungsFehler("Dieser Schlüssel ist dort für diesen Benutzer bereits zugewiesen.");
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merkt eine Verwendung zum Entfernen vor; wirksam mit dem naechsten Ausrollen. */
|
||||||
|
export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Promise<void> {
|
||||||
|
const verwendung = await prisma.publicVerwendung.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { schluessel: { select: { fingerprint: true } }, host: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!verwendung || verwendung.zustand === "ENTFERNT")
|
||||||
|
throw new VerwendungsFehler("Diese Zuweisung gibt es nicht.");
|
||||||
|
// Nie ausgerollt: einfach weg, auf dem Host steht noch nichts.
|
||||||
|
const nieAusgerollt = verwendung.zustand === "GEPLANT" && !verwendung.zuletztAngewendetAm;
|
||||||
|
await prisma.publicVerwendung.update({
|
||||||
|
where: { id },
|
||||||
|
data: { zustand: nieAusgerollt ? "ENTFERNT" : "ENTFERNEN" },
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "verwendung.public.entfernen",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: verwendung.host?.name ?? null,
|
||||||
|
zielbenutzer: verwendung.zielbenutzer,
|
||||||
|
fingerprint: verwendung.schluessel.fingerprint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */
|
||||||
|
export async function trockenlaufStarten(
|
||||||
|
auswahl: { schluesselId?: 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> {
|
||||||
|
const ziele = auswahl.ziele ?? [
|
||||||
|
...(await zieleVonVerwendungen({
|
||||||
|
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||||
|
...(auswahl.hostId ? { hostId: auswahl.hostId } : {}),
|
||||||
|
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
|
||||||
|
})),
|
||||||
|
// Private Keys nur, wenn nicht gezielt einzelne Public-Verwendungen gemeint sind.
|
||||||
|
...(auswahl.verwendungIds || !mitPrivat
|
||||||
|
? []
|
||||||
|
: await zieleVonPrivatVerwendungen({
|
||||||
|
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||||
|
...(auswahl.hostId ? { hostId: auswahl.hostId } : {}),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
if (ziele.length === 0) throw new VerwendungsFehler("Es gibt nichts auszurollen.");
|
||||||
|
return vorgangEinreihen({
|
||||||
|
art: "TROCKENLAUF",
|
||||||
|
angefordertVonId: akteur.id,
|
||||||
|
schluesselId: auswahl.schluesselId ?? null,
|
||||||
|
hostId: auswahl.hostId ?? null,
|
||||||
|
eingabe: { ziele },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ausrollenStarten(trockenlaufId: string, akteur: Akteur): Promise<string> {
|
||||||
|
const trockenlauf = await prisma.vorgang.findUnique({ where: { id: trockenlaufId } });
|
||||||
|
if (!trockenlauf || trockenlauf.art !== "TROCKENLAUF" || trockenlauf.status !== "ERLEDIGT") {
|
||||||
|
throw new VerwendungsFehler("Erst den Trockenlauf abwarten.");
|
||||||
|
}
|
||||||
|
return vorgangEinreihen({
|
||||||
|
art: "AUSROLLEN",
|
||||||
|
angefordertVonId: akteur.id,
|
||||||
|
schluesselId: trockenlauf.schluesselId,
|
||||||
|
hostId: trockenlauf.hostId,
|
||||||
|
eingabe: { trockenlaufId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PrivateZuweisung = {
|
||||||
|
readonly schluesselId: string;
|
||||||
|
readonly hostId: string;
|
||||||
|
readonly zielbenutzer: string;
|
||||||
|
readonly pfad: string;
|
||||||
|
/** Optionaler Block in ~/.ssh/config des Zielbenutzers. */
|
||||||
|
readonly sshConfig?: { alias: string; hostName: string; benutzer: string; port: number } | null;
|
||||||
|
/** Hosts, deren Host-Key in known_hosts gepflegt wird. */
|
||||||
|
readonly knownHostsFuer?: string[];
|
||||||
|
readonly beimWiderruf?: "LOESCHEN" | "UMBENENNEN";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private Key einem Quell-Host zuweisen (#017). Nur Admins (Recht privateKeyAusrollen, prueft
|
||||||
|
* die Aktion). Ein passphrasengeschuetzter Key ist fuer automatische Anmeldungen ungeeignet –
|
||||||
|
* die Oberflaeche warnt, verboten ist er nicht (Freigabe Frage 6).
|
||||||
|
*/
|
||||||
|
export async function privateVerwendungAnlegen(eingabe: PrivateZuweisung, akteur: Akteur): Promise<string> {
|
||||||
|
if (!BENUTZERNAME_MUSTER.test(eingabe.zielbenutzer))
|
||||||
|
throw new VerwendungsFehler("Ungültiger Zielbenutzer.");
|
||||||
|
let pfad: string;
|
||||||
|
try {
|
||||||
|
pfad = privatPfadPruefen(eingabe.pfad);
|
||||||
|
if (eingabe.sshConfig) sshConfigPruefen({ ...eingabe.sshConfig, identityFile: pfad });
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof SshConfigFehler) throw new VerwendungsFehler(fehler.message);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const schluessel = await prisma.schluessel.findUnique({
|
||||||
|
where: { id: eingabe.schluesselId },
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
fingerprint: true,
|
||||||
|
kategorie: true,
|
||||||
|
privatGeloeschtAm: true,
|
||||||
|
masterKeyVersion: 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.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).
|
||||||
|
if (schluessel.masterKeyVersion === null) {
|
||||||
|
throw new VerwendungsFehler(
|
||||||
|
schluessel.privatGeloeschtAm
|
||||||
|
? "Der Private Key wurde aus SSHKeyVault gelöscht."
|
||||||
|
: "Zu diesem Schlüssel ist kein Private Key gespeichert.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const host = await prisma.host.findUnique({
|
||||||
|
where: { id: eingabe.hostId },
|
||||||
|
select: { name: true, archiviertAm: true },
|
||||||
|
});
|
||||||
|
if (!host || host.archiviertAm) throw new VerwendungsFehler("Den Host gibt es nicht (mehr).");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verwendung = await prisma.privateVerwendung.create({
|
||||||
|
data: {
|
||||||
|
schluesselId: eingabe.schluesselId,
|
||||||
|
hostId: eingabe.hostId,
|
||||||
|
zielbenutzer: eingabe.zielbenutzer,
|
||||||
|
pfad,
|
||||||
|
sshConfig: eingabe.sshConfig ?? undefined,
|
||||||
|
knownHostsFuer: eingabe.knownHostsFuer ?? [],
|
||||||
|
beimWiderruf: eingabe.beimWiderruf ?? "LOESCHEN",
|
||||||
|
angelegtVonId: akteur.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "verwendung.private.angelegt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: host.name,
|
||||||
|
zielbenutzer: eingabe.zielbenutzer,
|
||||||
|
fingerprint: schluessel.fingerprint,
|
||||||
|
details: { pfad, sshConfig: eingabe.sshConfig ?? null },
|
||||||
|
});
|
||||||
|
return verwendung.id;
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
|
||||||
|
throw new VerwendungsFehler(
|
||||||
|
"An diesem Pfad liegt für diesen Benutzer bereits ein verwalteter Schlüssel.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function privateVerwendungEntfernen(id: string, akteur: Akteur): Promise<void> {
|
||||||
|
const verwendung = await prisma.privateVerwendung.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { schluessel: { select: { fingerprint: true } }, host: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!verwendung || verwendung.zustand === "ENTFERNT")
|
||||||
|
throw new VerwendungsFehler("Diese Zuweisung gibt es nicht.");
|
||||||
|
const nieAusgerollt = verwendung.zustand === "GEPLANT" && !verwendung.zuletztAngewendetAm;
|
||||||
|
await prisma.privateVerwendung.update({
|
||||||
|
where: { id },
|
||||||
|
data: { zustand: nieAusgerollt ? "ENTFERNT" : "ENTFERNEN" },
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "verwendung.private.entfernen",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: verwendung.host.name,
|
||||||
|
zielbenutzer: verwendung.zielbenutzer,
|
||||||
|
fingerprint: verwendung.schluessel.fingerprint,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { vorlagePruefen, VorlagenFehler, type VorlageEingabe } from "@/lib/vorlagen";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/** Vorlagen anlegen, bearbeiten, archivieren (#019). Eingebaute lassen sich nur bearbeiten. */
|
||||||
|
type Akteur = { readonly id: string; readonly name: string };
|
||||||
|
|
||||||
|
function daten(eingabe: VorlageEingabe) {
|
||||||
|
const v = vorlagePruefen(eingabe);
|
||||||
|
return {
|
||||||
|
name: v.name.trim(),
|
||||||
|
beschreibung: v.beschreibung?.trim() || null,
|
||||||
|
schluesselTyp: v.schluesselTyp,
|
||||||
|
kategorie: v.kategorie,
|
||||||
|
standardLaufzeitTage: v.standardLaufzeitTage ?? null,
|
||||||
|
zielbenutzer: v.zielbenutzer,
|
||||||
|
zielbenutzerAnlegen: v.zielbenutzerAnlegen,
|
||||||
|
sudoModus: v.sudoModus,
|
||||||
|
sudoBefehle: v.sudoModus === "BEFEHLE" ? v.sudoBefehle.map((b) => b.trim().replace(/\s+/g, " ")) : [],
|
||||||
|
authorizedKeysOptionen: v.authorizedKeysOptionen?.trim() || null,
|
||||||
|
sshConfigAlias: v.sshConfigAlias?.trim() || null,
|
||||||
|
knownHostsEintragen: v.knownHostsEintragen,
|
||||||
|
privatNachAusrollenLoeschen: v.privatNachAusrollenLoeschen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function eindeutig(fehler: unknown): never {
|
||||||
|
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
|
||||||
|
throw new VorlagenFehler("Eine Vorlage mit diesem Namen gibt es bereits.", "name");
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vorlageAnlegen(eingabe: VorlageEingabe, akteur: Akteur): Promise<string> {
|
||||||
|
const werte = daten(eingabe);
|
||||||
|
const vorlage = await prisma.vorlage.create({ data: werte }).catch(eindeutig);
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "vorlage.angelegt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { name: werte.name },
|
||||||
|
});
|
||||||
|
return vorlage.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vorlageBearbeiten(id: string, eingabe: VorlageEingabe, akteur: Akteur): Promise<void> {
|
||||||
|
const werte = daten(eingabe);
|
||||||
|
const alt = await prisma.vorlage.findUnique({ where: { id } });
|
||||||
|
if (!alt || alt.archiviertAm) throw new VorlagenFehler("Diese Vorlage gibt es nicht (mehr).");
|
||||||
|
await prisma.vorlage.update({ where: { id }, data: werte }).catch(eindeutig);
|
||||||
|
const geaendert = Object.entries(werte)
|
||||||
|
.filter(([feld, wert]) => JSON.stringify(alt[feld as keyof typeof alt]) !== JSON.stringify(wert))
|
||||||
|
.map(([feld]) => feld);
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "vorlage.geaendert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { name: werte.name, geaendert },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function vorlageArchivieren(id: string, akteur: Akteur): Promise<void> {
|
||||||
|
const vorlage = await prisma.vorlage.findUnique({ where: { id } });
|
||||||
|
if (!vorlage || vorlage.archiviertAm) throw new VorlagenFehler("Diese Vorlage gibt es nicht (mehr).");
|
||||||
|
if (vorlage.eingebaut) throw new VorlagenFehler("Eingebaute Vorlagen lassen sich nicht archivieren.");
|
||||||
|
await prisma.vorlage.update({ where: { id }, data: { archiviertAm: new Date() } });
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "vorlage.archiviert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { name: vorlage.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -35,8 +35,20 @@ export async function vorgangEinreihen(daten: VorgangAnlegen): Promise<string> {
|
|||||||
return vorgang.id;
|
return vorgang.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. */
|
/**
|
||||||
|
* Was die Oberflaeche ueber einen Vorgang erfahren darf – ohne Job-Interna. Eine bereitliegende
|
||||||
|
* Private-Key-Abholung (#014) wird nie ausgeliefert, auch nicht verschluesselt: abgeholt wird
|
||||||
|
* nur ueber src/server/services/privatekey.ts.
|
||||||
|
*/
|
||||||
export async function vorgangLesen(id: string) {
|
export async function vorgangLesen(id: string) {
|
||||||
|
const vorgang = await vorgangRoh(id);
|
||||||
|
if (vorgang?.art !== "PRIVATE_KEY_ABHOLEN") return vorgang;
|
||||||
|
const ergebnis = vorgang.ergebnis;
|
||||||
|
const bereit = typeof ergebnis === "object" && ergebnis !== null && "abholung" in ergebnis;
|
||||||
|
return { ...vorgang, ergebnis: bereit ? { bereit: true } : ergebnis };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vorgangRoh(id: string) {
|
||||||
return prisma.vorgang.findUnique({
|
return prisma.vorgang.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { Client } from "ssh2";
|
||||||
|
import type { Host } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { BlockFehler, blockSetzen } from "@/lib/ssh/authorized-keys";
|
||||||
|
import { diffVerdichten, type DiffZeile } from "@/lib/ssh/zeilen-diff";
|
||||||
|
import { sollFuer, type Ziel } from "@/server/services/soll";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||||
|
import { authorizedKeysHolen, authorizedKeysSchreiben, inhaltHash } from "@/worker/ssh/ssh-dateien";
|
||||||
|
import { hostVerbinden } from "@/worker/ssh/host";
|
||||||
|
import { privatAnwenden, privatPlanen, type DateiPlan, type PrivatPlan } from "@/worker/ablaeufe/privat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public Keys (#016) und Private Keys (#017) ausrollen, in zwei Schritten:
|
||||||
|
*
|
||||||
|
* 1. TROCKENLAUF: je Ziel (Host, Benutzer) die Datei lesen, den Block aus dem Soll setzen und
|
||||||
|
* Diff, Hinweise und beide Hashes festhalten. Auf dem Host aendert sich nichts.
|
||||||
|
* 2. AUSROLLEN: genau diesen Plan ausfuehren. Hat sich die Datei oder das Soll seitdem
|
||||||
|
* geaendert, bricht das Ziel ab (optimistische Sperre ueber die Hashes) – der Mensch
|
||||||
|
* soll nie etwas anderes schreiben, als er gesehen hat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** So lange darf ein Trockenlauf alt sein, bevor er ausgefuehrt wird. */
|
||||||
|
export const TROCKENLAUF_GUELTIG_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
export type PlanZiel = {
|
||||||
|
readonly art: "public" | "privat";
|
||||||
|
readonly hostId: string;
|
||||||
|
readonly hostName: string;
|
||||||
|
readonly zielbenutzer: string;
|
||||||
|
readonly pfad?: string;
|
||||||
|
readonly vorher?: string;
|
||||||
|
readonly nachher?: string;
|
||||||
|
readonly geaendert: boolean;
|
||||||
|
readonly diff: DiffZeile[];
|
||||||
|
readonly hinweise: string[];
|
||||||
|
readonly fehler?: string;
|
||||||
|
/** Nur bei privat: je Datei Aktion und Hashes (#017). */
|
||||||
|
readonly dateien?: DateiPlan[];
|
||||||
|
readonly verwendungIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ZielMitArt = Ziel & { readonly art: "public" | "privat" };
|
||||||
|
|
||||||
|
function zielSchluessel(ziel: { hostId: string; zielbenutzer: string; art?: string }): string {
|
||||||
|
return `${ziel.hostId}\u0000${ziel.zielbenutzer}\u0000${ziel.art ?? "public"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Plan = { readonly ziele: PlanZiel[] };
|
||||||
|
|
||||||
|
function hashVon(text: string): string {
|
||||||
|
return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fehlerText(fehler: unknown): string {
|
||||||
|
if (fehler instanceof VorgangsFehler || fehler instanceof BlockFehler) return fehler.message;
|
||||||
|
return "Interner Fehler – Details im Worker-Log.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function zieleLesen(eingabe: unknown): ZielMitArt[] {
|
||||||
|
const ziele = (eingabe as { ziele?: unknown } | null)?.ziele;
|
||||||
|
if (!Array.isArray(ziele) || ziele.length === 0)
|
||||||
|
throw new VorgangsFehler("Der Trockenlauf nennt keine Ziele.");
|
||||||
|
return ziele.map((z) => {
|
||||||
|
const ziel = z as Partial<Ziel>;
|
||||||
|
if (typeof ziel.hostId !== "string" || typeof ziel.zielbenutzer !== "string") {
|
||||||
|
throw new VorgangsFehler("Ungültiges Ziel im Trockenlauf.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
hostId: ziel.hostId,
|
||||||
|
zielbenutzer: ziel.zielbenutzer,
|
||||||
|
art: ziel.art === "privat" ? "privat" : "public",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ziele nach Host gruppieren: je Host genau eine SSH-Verbindung. */
|
||||||
|
async function jeHost<T>(
|
||||||
|
ziele: readonly ZielMitArt[],
|
||||||
|
arbeit: (host: Host, verbindung: Client, ziel: ZielMitArt) => Promise<T>,
|
||||||
|
beiFehler: (host: Pick<Host, "id" | "name">, ziel: ZielMitArt, fehler: unknown) => T,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const nachHost = new Map<string, ZielMitArt[]>();
|
||||||
|
for (const ziel of ziele) nachHost.set(ziel.hostId, [...(nachHost.get(ziel.hostId) ?? []), ziel]);
|
||||||
|
const ergebnisse: T[] = [];
|
||||||
|
for (const [hostId, hostZiele] of nachHost) {
|
||||||
|
const host = await prisma.host.findUnique({ where: { id: hostId } });
|
||||||
|
if (!host || host.archiviertAm) {
|
||||||
|
for (const ziel of hostZiele) {
|
||||||
|
ergebnisse.push(
|
||||||
|
beiFehler(
|
||||||
|
{ id: hostId, name: host?.name ?? "?" },
|
||||||
|
ziel,
|
||||||
|
new VorgangsFehler("Host ist archiviert oder fehlt."),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let verbindung: Client;
|
||||||
|
try {
|
||||||
|
verbindung = await hostVerbinden(host);
|
||||||
|
} catch (fehler) {
|
||||||
|
for (const ziel of hostZiele) ergebnisse.push(beiFehler(host, ziel, fehler));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (const ziel of hostZiele) {
|
||||||
|
try {
|
||||||
|
ergebnisse.push(await arbeit(host, verbindung, ziel));
|
||||||
|
} catch (fehler) {
|
||||||
|
ergebnisse.push(beiFehler(host, ziel, fehler));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
verbindung.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ergebnisse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Private-Key-Plan in die gemeinsame Form bringen: Diffs von config/known_hosts, Dateiliste. */
|
||||||
|
function privatPlanZiel(host: Host, benutzer: string, plan: PrivatPlan): PlanZiel {
|
||||||
|
const geaendert = plan.dateien.some((d) => d.aktion !== "keine");
|
||||||
|
return {
|
||||||
|
art: "privat",
|
||||||
|
hostId: host.id,
|
||||||
|
hostName: host.name,
|
||||||
|
zielbenutzer: benutzer,
|
||||||
|
geaendert,
|
||||||
|
diff: plan.dateien.flatMap((d) => (d.diff && d.aktion !== "keine" ? d.diff : [])),
|
||||||
|
hinweise: plan.dateien.filter((d) => d.hinweis).map((d) => `${d.pfad}: ${d.hinweis}`),
|
||||||
|
dateien: plan.dateien,
|
||||||
|
verwendungIds: plan.verwendungIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trockenlaufHandler: Handler = async ({ vorgang }) => {
|
||||||
|
const ziele = zieleLesen(vorgang.eingabe);
|
||||||
|
const planZiele = await jeHost<PlanZiel>(
|
||||||
|
ziele,
|
||||||
|
async (host, verbindung, ziel) => {
|
||||||
|
const benutzer = ziel.zielbenutzer;
|
||||||
|
if (ziel.art === "privat")
|
||||||
|
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);
|
||||||
|
return {
|
||||||
|
art: "public",
|
||||||
|
hostId: host.id,
|
||||||
|
hostName: host.name,
|
||||||
|
zielbenutzer: benutzer,
|
||||||
|
pfad: stand.pfad,
|
||||||
|
vorher: stand.sha256,
|
||||||
|
nachher: hashVon(ergebnis.inhalt),
|
||||||
|
geaendert: ergebnis.geaendert || !stand.vorhanden,
|
||||||
|
diff: diffVerdichten(ergebnis.diff),
|
||||||
|
hinweise: ergebnis.hinweise,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
(host, ziel, fehler) => ({
|
||||||
|
art: ziel.art,
|
||||||
|
hostId: host.id,
|
||||||
|
hostName: host.name,
|
||||||
|
zielbenutzer: ziel.zielbenutzer,
|
||||||
|
geaendert: false,
|
||||||
|
diff: [],
|
||||||
|
hinweise: [],
|
||||||
|
fehler: fehlerText(fehler),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return { plan: { ziele: planZiele }, ergebnis: { ziele: planZiele.length } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ausrollenHandler: Handler = async ({ vorgang }) => {
|
||||||
|
const trockenlaufId = (vorgang.eingabe as { trockenlaufId?: unknown } | null)?.trockenlaufId;
|
||||||
|
if (typeof trockenlaufId !== "string") throw new VorgangsFehler("Kein Trockenlauf angegeben.");
|
||||||
|
const trockenlauf = await prisma.vorgang.findUnique({ where: { id: trockenlaufId } });
|
||||||
|
if (
|
||||||
|
!trockenlauf ||
|
||||||
|
trockenlauf.art !== "TROCKENLAUF" ||
|
||||||
|
trockenlauf.status !== "ERLEDIGT" ||
|
||||||
|
!trockenlauf.plan
|
||||||
|
) {
|
||||||
|
throw new VorgangsFehler("Der Trockenlauf ist nicht abgeschlossen.");
|
||||||
|
}
|
||||||
|
if (Date.now() - (trockenlauf.beendetAm ?? trockenlauf.angelegtAm).getTime() > TROCKENLAUF_GUELTIG_MS) {
|
||||||
|
throw new VorgangsFehler("Der Trockenlauf ist älter als 15 Minuten. Bitte neu starten.");
|
||||||
|
}
|
||||||
|
const bereitsAusgefuehrt = await prisma.vorgang.findFirst({
|
||||||
|
where: {
|
||||||
|
art: "AUSROLLEN",
|
||||||
|
id: { not: vorgang.id },
|
||||||
|
status: { in: ["LAEUFT", "ERLEDIGT"] },
|
||||||
|
eingabe: { equals: { trockenlaufId } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (bereitsAusgefuehrt) throw new VorgangsFehler("Dieser Trockenlauf wurde bereits ausgeführt.");
|
||||||
|
|
||||||
|
const plan = trockenlauf.plan as unknown as Plan;
|
||||||
|
const auszufuehren = plan.ziele.filter((ziel) => ziel.geaendert && !ziel.fehler);
|
||||||
|
const akteur = vorgang.angefordertVonId
|
||||||
|
? await prisma.benutzer.findUnique({
|
||||||
|
where: { id: vorgang.angefordertVonId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
type Ausgang = {
|
||||||
|
readonly hostId: string;
|
||||||
|
readonly zielbenutzer: string;
|
||||||
|
readonly art: "public" | "privat";
|
||||||
|
readonly erfolg: boolean;
|
||||||
|
readonly fehler?: string;
|
||||||
|
};
|
||||||
|
const geplant = new Map(auszufuehren.map((z) => [zielSchluessel(z), z]));
|
||||||
|
|
||||||
|
const ausgaenge = await jeHost<Ausgang>(
|
||||||
|
auszufuehren.map((z) => ({ hostId: z.hostId, zielbenutzer: z.zielbenutzer, art: z.art ?? "public" })),
|
||||||
|
async (host, verbindung, zielAngabe) => {
|
||||||
|
const ziel = geplant.get(zielSchluessel(zielAngabe))!;
|
||||||
|
const benutzer = zielAngabe.zielbenutzer;
|
||||||
|
if (zielAngabe.art === "privat") {
|
||||||
|
const anzahl = await privatAnwenden(verbindung, host, benutzer, {
|
||||||
|
dateien: ziel.dateien ?? [],
|
||||||
|
verwendungIds: ziel.verwendungIds ?? [],
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur: akteur ?? "System",
|
||||||
|
aktion: "privatekey.ausgerollt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: host.name,
|
||||||
|
zielbenutzer: benutzer,
|
||||||
|
// Nur Pfade und Aktionen – nie Inhalte.
|
||||||
|
diff: (ziel.dateien ?? [])
|
||||||
|
.filter((d) => d.aktion !== "keine")
|
||||||
|
.map((d) => `${d.aktion} ${d.pfad}`)
|
||||||
|
.join("\n"),
|
||||||
|
details: { dateien: anzahl, trockenlaufId },
|
||||||
|
});
|
||||||
|
return { hostId: host.id, zielbenutzer: benutzer, art: "privat", erfolg: true };
|
||||||
|
}
|
||||||
|
const stand = await authorizedKeysHolen(verbindung, host, benutzer);
|
||||||
|
if (stand.sha256 !== ziel.vorher) {
|
||||||
|
throw new VorgangsFehler(
|
||||||
|
`authorized_keys von ${benutzer}@${host.name} wurde seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const soll = await sollFuer(host.id, benutzer);
|
||||||
|
const ergebnis = blockSetzen(stand.inhalt, soll.eintraege, soll.pflicht);
|
||||||
|
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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await authorizedKeysSchreiben(verbindung, host, benutzer, ergebnis.inhalt, stand.sha256);
|
||||||
|
await protokollieren({
|
||||||
|
akteur: akteur ?? "System",
|
||||||
|
aktion: "authorized_keys.geschrieben",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: host.name,
|
||||||
|
zielbenutzer: benutzer,
|
||||||
|
diff: ergebnis.diff
|
||||||
|
.filter((z) => z.art !== "kontext")
|
||||||
|
.map((z) => `${z.art === "hinzu" ? "+" : "-"} ${z.text}`)
|
||||||
|
.join("\n"),
|
||||||
|
details: {
|
||||||
|
vorher: stand.sha256,
|
||||||
|
nachher: inhaltHash(Buffer.from(ergebnis.inhalt, "utf8")),
|
||||||
|
trockenlaufId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { hostId: host.id, zielbenutzer: benutzer, art: "public", erfolg: true };
|
||||||
|
},
|
||||||
|
(host, ziel, fehler) => ({
|
||||||
|
hostId: host.id,
|
||||||
|
zielbenutzer: ziel.zielbenutzer,
|
||||||
|
art: ziel.art,
|
||||||
|
erfolg: false,
|
||||||
|
fehler: fehlerText(fehler),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zustand der Verwendungen nachziehen – auch fuer Ziele ohne Aenderung, die schon stimmten.
|
||||||
|
const fehlgeschlagen = new Set(ausgaenge.filter((a) => !a.erfolg).map(zielSchluessel));
|
||||||
|
// Fehlschlaege zuletzt: eine Gruppen-Verwendung, die auf einem Host scheitert, soll
|
||||||
|
// FEHLGESCHLAGEN bleiben, auch wenn ein anderer Host danach erfolgreich war.
|
||||||
|
const reihenfolge = plan.ziele
|
||||||
|
.filter((z) => !z.fehler)
|
||||||
|
.sort(
|
||||||
|
(a, b) => Number(fehlgeschlagen.has(zielSchluessel(a))) - Number(fehlgeschlagen.has(zielSchluessel(b))),
|
||||||
|
);
|
||||||
|
for (const ziel of reihenfolge) {
|
||||||
|
const schluessel = zielSchluessel(ziel);
|
||||||
|
if (ziel.art === "privat") {
|
||||||
|
const ids = ziel.verwendungIds ?? [];
|
||||||
|
const jetzt = new Date();
|
||||||
|
if (fehlgeschlagen.has(schluessel)) {
|
||||||
|
await prisma.privateVerwendung.updateMany({
|
||||||
|
where: { id: { in: ids }, zustand: { not: "ENTFERNEN" } },
|
||||||
|
data: { zustand: "FEHLGESCHLAGEN" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.privateVerwendung.updateMany({
|
||||||
|
where: { id: { in: ids }, zustand: "ENTFERNEN" },
|
||||||
|
data: { zustand: "ENTFERNT", zuletztAngewendetAm: jetzt },
|
||||||
|
});
|
||||||
|
await prisma.privateVerwendung.updateMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
zustand: { in: ["GEPLANT", "ABWEICHUNG", "FEHLGESCHLAGEN", "AUSGEROLLT"] },
|
||||||
|
},
|
||||||
|
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const soll = await sollFuer(ziel.hostId, ziel.zielbenutzer);
|
||||||
|
const jetzt = new Date();
|
||||||
|
if (fehlgeschlagen.has(schluessel)) {
|
||||||
|
await prisma.publicVerwendung.updateMany({
|
||||||
|
where: { id: { in: soll.verwendungIds.drin } },
|
||||||
|
data: { zustand: "FEHLGESCHLAGEN" },
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await prisma.publicVerwendung.updateMany({
|
||||||
|
where: { id: { in: soll.verwendungIds.drin } },
|
||||||
|
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
||||||
|
});
|
||||||
|
await prisma.publicVerwendung.updateMany({
|
||||||
|
where: { id: { in: soll.verwendungIds.raus }, zustand: "ENTFERNEN" },
|
||||||
|
data: { zustand: "ENTFERNT", zuletztAngewendetAm: jetzt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fehler = ausgaenge.filter((a) => !a.erfolg);
|
||||||
|
if (fehler.length > 0 && fehler.length === ausgaenge.length) {
|
||||||
|
throw new VorgangsFehler(fehler.map((f) => f.fehler).join(" "));
|
||||||
|
}
|
||||||
|
return { ergebnis: { geschrieben: ausgaenge.filter((a) => a.erfolg).length, fehler } };
|
||||||
|
};
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import type { Client } from "ssh2";
|
||||||
|
import type { Host } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { shellQuote } from "@/lib/shell";
|
||||||
|
import { knownHostsZeile, sshConfigZeilen, type SshConfigEintrag } from "@/lib/ssh/ssh-config";
|
||||||
|
import { textblockSetzen } from "@/lib/ssh/textblock";
|
||||||
|
import { diffVerdichten, type DiffZeile } from "@/lib/ssh/zeilen-diff";
|
||||||
|
import { VorgangsFehler } from "@/worker/ausfuehren";
|
||||||
|
import { privateKeyLaden } from "@/worker/geheimnisse";
|
||||||
|
import { dateiHolen, dateiSchreiben, KEINE_DATEI, relativenPfadPruefen } from "@/worker/ssh/ssh-dateien";
|
||||||
|
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private Keys auf einen Quell-Host ausrollen (#017): Schluesseldatei, .pub, verwalteter
|
||||||
|
* Block in ~/.ssh/config und known_hosts. Wie beim Public Key in zwei Schritten (Plan,
|
||||||
|
* dann Ausfuehren), mit Hash je Datei als optimistischer Sperre.
|
||||||
|
*
|
||||||
|
* Der Inhalt des Private Keys steht nie im Plan – nur ob die Datei angelegt, geaendert oder
|
||||||
|
* entfernt wird. Eine vorhandene fremde Datei am Zielpfad wird nie ueberschrieben.
|
||||||
|
*/
|
||||||
|
export type DateiArt = "privateKey" | "publicKey" | "config" | "knownHosts";
|
||||||
|
export type DateiAktion = "anlegen" | "aendern" | "keine" | "entfernen";
|
||||||
|
|
||||||
|
export type DateiPlan = {
|
||||||
|
readonly art: DateiArt;
|
||||||
|
readonly pfad: string;
|
||||||
|
readonly aktion: DateiAktion;
|
||||||
|
readonly vorher: string;
|
||||||
|
readonly nachher: string;
|
||||||
|
/** Nur fuer config und known_hosts. */
|
||||||
|
readonly diff?: DiffZeile[];
|
||||||
|
readonly hinweis?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Gewollt = {
|
||||||
|
readonly art: DateiArt;
|
||||||
|
readonly pfad: string;
|
||||||
|
/** null = Datei soll weg (Widerruf). */
|
||||||
|
readonly inhalt: Buffer | null;
|
||||||
|
readonly modus: "0600" | "0644";
|
||||||
|
readonly umbenennen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IM_SOLL = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"];
|
||||||
|
|
||||||
|
function hash(inhalt: Buffer | null): string {
|
||||||
|
return inhalt === null ? KEINE_DATEI : createHash("sha256").update(inhalt).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Was in ~/.ssh eines Benutzers auf `host` liegen soll. Private Keys nur hier entschluesselt. */
|
||||||
|
async function gewollteDateien(
|
||||||
|
host: Host,
|
||||||
|
benutzer: string,
|
||||||
|
): Promise<{ dateien: Gewollt[]; verwendungIds: string[] }> {
|
||||||
|
const verwendungen = await prisma.privateVerwendung.findMany({
|
||||||
|
where: { hostId: host.id, zielbenutzer: benutzer, zustand: { not: "ENTFERNT" } },
|
||||||
|
include: { schluessel: { select: { id: true, publicKey: true, privatGeloeschtAm: true, status: true } } },
|
||||||
|
orderBy: [{ angelegtAm: "asc" }, { id: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateien: Gewollt[] = [];
|
||||||
|
const config: SshConfigEintrag[] = [];
|
||||||
|
const knownHosts: string[] = [];
|
||||||
|
for (const verwendung of verwendungen) {
|
||||||
|
const pfad = relativenPfadPruefen(verwendung.pfad);
|
||||||
|
const bleibt = IM_SOLL.includes(verwendung.zustand);
|
||||||
|
if (!bleibt) {
|
||||||
|
dateien.push({
|
||||||
|
art: "privateKey",
|
||||||
|
pfad,
|
||||||
|
inhalt: null,
|
||||||
|
modus: "0600",
|
||||||
|
umbenennen: verwendung.beimWiderruf === "UMBENENNEN",
|
||||||
|
});
|
||||||
|
dateien.push({ art: "publicKey", pfad: `${pfad}.pub`, inhalt: null, modus: "0644" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (verwendung.schluessel.privatGeloeschtAm) {
|
||||||
|
throw new VorgangsFehler(
|
||||||
|
"Der Private Key wurde aus SSHKeyVault gelöscht und lässt sich nicht mehr ausrollen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dateien.push({
|
||||||
|
art: "privateKey",
|
||||||
|
pfad,
|
||||||
|
inhalt: await privateKeyLaden(verwendung.schluessel.id),
|
||||||
|
modus: "0600",
|
||||||
|
});
|
||||||
|
dateien.push({
|
||||||
|
art: "publicKey",
|
||||||
|
pfad: `${pfad}.pub`,
|
||||||
|
inhalt: Buffer.from(`${verwendung.schluessel.publicKey.trim()}\n`),
|
||||||
|
modus: "0644",
|
||||||
|
});
|
||||||
|
const sshConfig = verwendung.sshConfig as {
|
||||||
|
alias?: string;
|
||||||
|
hostName?: string;
|
||||||
|
benutzer?: string;
|
||||||
|
port?: number;
|
||||||
|
} | null;
|
||||||
|
if (sshConfig?.alias && sshConfig.hostName && sshConfig.benutzer) {
|
||||||
|
config.push({
|
||||||
|
alias: sshConfig.alias,
|
||||||
|
hostName: sshConfig.hostName,
|
||||||
|
benutzer: sshConfig.benutzer,
|
||||||
|
port: sshConfig.port ?? 22,
|
||||||
|
identityFile: `~/${pfad}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const zielHostId of verwendung.knownHostsFuer) {
|
||||||
|
const ziel = await prisma.host.findUnique({
|
||||||
|
where: { id: zielHostId },
|
||||||
|
select: { adresse: true, port: true, hostSchluessel: { where: { status: "VERTRAUT" } } },
|
||||||
|
});
|
||||||
|
if (!ziel) continue;
|
||||||
|
// Der Name, unter dem der Quell-Host das Ziel anspricht: der HostName aus der config,
|
||||||
|
// sonst die Adresse, unter der sshkv es kennt.
|
||||||
|
const name = sshConfig?.hostName ?? ziel.adresse;
|
||||||
|
const port = sshConfig?.port ?? ziel.port;
|
||||||
|
for (const key of ziel.hostSchluessel)
|
||||||
|
knownHosts.push(knownHostsZeile(name, port, key.algorithmus, key.publicKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fuer config und known_hosts stehen hier nur die Blockzeilen; die ganze Datei entsteht
|
||||||
|
// erst zusammen mit dem vorhandenen Inhalt (blockInhalt).
|
||||||
|
dateien.push({
|
||||||
|
art: "config",
|
||||||
|
pfad: ".ssh/config",
|
||||||
|
inhalt: Buffer.from(sshConfigZeilen(config).join("\n")),
|
||||||
|
modus: "0600",
|
||||||
|
});
|
||||||
|
dateien.push({
|
||||||
|
art: "knownHosts",
|
||||||
|
pfad: ".ssh/known_hosts",
|
||||||
|
inhalt: Buffer.from([...new Set(knownHosts)].join("\n")),
|
||||||
|
modus: "0600",
|
||||||
|
});
|
||||||
|
return { dateien, verwendungIds: verwendungen.map((v) => v.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PrivatPlan = { readonly dateien: DateiPlan[]; readonly verwendungIds: string[] };
|
||||||
|
|
||||||
|
/** Inhalt nach dem Setzen des Blocks fuer config/known_hosts. */
|
||||||
|
function blockInhalt(
|
||||||
|
datei: Gewollt,
|
||||||
|
vorhanden: string,
|
||||||
|
): { inhalt: string; diff: DiffZeile[]; geaendert: boolean } {
|
||||||
|
const zeilen = datei.inhalt && datei.inhalt.length ? datei.inhalt.toString("utf8").split("\n") : [];
|
||||||
|
const ergebnis = textblockSetzen(vorhanden, zeilen, datei.art === "config" ? "anfang" : "ende");
|
||||||
|
return { inhalt: ergebnis.inhalt, diff: diffVerdichten(ergebnis.diff), geaendert: ergebnis.geaendert };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function privatPlanen(verbindung: Client, host: Host, benutzer: string): Promise<PrivatPlan> {
|
||||||
|
const { dateien, verwendungIds } = await gewollteDateien(host, benutzer);
|
||||||
|
const plan: DateiPlan[] = [];
|
||||||
|
try {
|
||||||
|
for (const datei of dateien) {
|
||||||
|
const stand = await dateiHolen(verbindung, host, benutzer, datei.pfad);
|
||||||
|
if (datei.art === "config" || datei.art === "knownHosts") {
|
||||||
|
const neu = blockInhalt(datei, stand.inhalt);
|
||||||
|
plan.push({
|
||||||
|
art: datei.art,
|
||||||
|
pfad: stand.pfad,
|
||||||
|
aktion: !neu.geaendert ? "keine" : stand.vorhanden ? "aendern" : "anlegen",
|
||||||
|
vorher: stand.sha256,
|
||||||
|
nachher: hash(Buffer.from(neu.inhalt, "utf8")),
|
||||||
|
diff: neu.diff,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (datei.inhalt === null) {
|
||||||
|
plan.push({
|
||||||
|
art: datei.art,
|
||||||
|
pfad: stand.pfad,
|
||||||
|
aktion: stand.vorhanden ? "entfernen" : "keine",
|
||||||
|
vorher: stand.sha256,
|
||||||
|
nachher: KEINE_DATEI,
|
||||||
|
...(datei.umbenennen ? { hinweis: "wird umbenannt statt gelöscht" } : {}),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nachher = hash(datei.inhalt);
|
||||||
|
if (stand.vorhanden && stand.sha256 !== nachher && datei.art === "privateKey") {
|
||||||
|
throw new VorgangsFehler(
|
||||||
|
`${stand.pfad} auf ${host.name} existiert schon mit anderem Inhalt. sshkv überschreibt keine fremden Schlüssel – bitte anderen Pfad wählen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
plan.push({
|
||||||
|
art: datei.art,
|
||||||
|
pfad: stand.pfad,
|
||||||
|
aktion: stand.sha256 === nachher ? "keine" : stand.vorhanden ? "aendern" : "anlegen",
|
||||||
|
vorher: stand.sha256,
|
||||||
|
nachher,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const datei of dateien) if (datei.art === "privateKey") datei.inhalt?.fill(0);
|
||||||
|
}
|
||||||
|
return { dateien: plan, verwendungIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt den Plan aus. Berechnet alles neu und vergleicht beide Hashes je Datei mit dem Plan –
|
||||||
|
* jede Abweichung bricht ab, bevor die erste Datei geschrieben wird.
|
||||||
|
*/
|
||||||
|
export async function privatAnwenden(
|
||||||
|
verbindung: Client,
|
||||||
|
host: Host,
|
||||||
|
benutzer: string,
|
||||||
|
geplant: PrivatPlan,
|
||||||
|
): Promise<number> {
|
||||||
|
const aktuell = await privatPlanen(verbindung, host, benutzer);
|
||||||
|
const schluessel = (d: DateiPlan) => `${d.art}\u0000${d.pfad}`;
|
||||||
|
const erwartet = new Map(geplant.dateien.map((d) => [schluessel(d), d]));
|
||||||
|
if (aktuell.dateien.length !== geplant.dateien.length) {
|
||||||
|
throw new VorgangsFehler(
|
||||||
|
`Die Private-Key-Zuweisungen für ${benutzer}@${host.name} haben sich geändert. Bitte neuen Trockenlauf starten.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const datei of aktuell.dateien) {
|
||||||
|
const plan = erwartet.get(schluessel(datei));
|
||||||
|
if (!plan || plan.vorher !== datei.vorher || plan.nachher !== datei.nachher) {
|
||||||
|
throw new VorgangsFehler(
|
||||||
|
`${datei.pfad} auf ${host.name} oder die Zuweisungen haben sich seit dem Trockenlauf geändert. Bitte neuen Trockenlauf starten.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dateien } = await gewollteDateien(host, benutzer);
|
||||||
|
let geschrieben = 0;
|
||||||
|
try {
|
||||||
|
for (const datei of dateien) {
|
||||||
|
const plan = aktuell.dateien.find((d) => d.art === datei.art && d.pfad.endsWith(`/${datei.pfad}`));
|
||||||
|
if (!plan || plan.aktion === "keine") continue;
|
||||||
|
if (plan.aktion === "entfernen") {
|
||||||
|
await dateiEntfernen(verbindung, host, benutzer, datei.pfad, Boolean(datei.umbenennen));
|
||||||
|
} else if (datei.art === "config" || datei.art === "knownHosts") {
|
||||||
|
const stand = await dateiHolen(verbindung, host, benutzer, datei.pfad);
|
||||||
|
await dateiSchreiben(
|
||||||
|
verbindung,
|
||||||
|
host,
|
||||||
|
benutzer,
|
||||||
|
datei.pfad,
|
||||||
|
blockInhalt(datei, stand.inhalt).inhalt,
|
||||||
|
plan.vorher,
|
||||||
|
datei.modus,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Keine Sicherung eines alten Private Keys: sie laege als zweite Kopie auf dem Host.
|
||||||
|
await dateiSchreiben(
|
||||||
|
verbindung,
|
||||||
|
host,
|
||||||
|
benutzer,
|
||||||
|
datei.pfad,
|
||||||
|
datei.inhalt!,
|
||||||
|
plan.vorher,
|
||||||
|
datei.modus,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
geschrieben += 1;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
for (const datei of dateien) if (datei.art === "privateKey") datei.inhalt?.fill(0);
|
||||||
|
}
|
||||||
|
return geschrieben;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loescht oder benennt eine Datei unter ~/.ssh um (als Benutzer). */
|
||||||
|
async function dateiEntfernen(
|
||||||
|
verbindung: Client,
|
||||||
|
host: Host,
|
||||||
|
benutzer: string,
|
||||||
|
pfad: string,
|
||||||
|
umbenennen: boolean,
|
||||||
|
) {
|
||||||
|
const relativ = relativenPfadPruefen(pfad);
|
||||||
|
const skript = `set -eu
|
||||||
|
B=${shellQuote(benutzer)}
|
||||||
|
H=$(getent passwd "$B" | cut -d: -f6)
|
||||||
|
D="$H/"${shellQuote(relativ)}
|
||||||
|
${umbenennen ? 'runuser -u "$B" -- mv "$D" "$D.sshkv-widerrufen.$(date +%Y%m%d-%H%M%S)"' : 'runuser -u "$B" -- rm -f -- "$D"'}
|
||||||
|
echo SSHKV-ENTFERNT`;
|
||||||
|
const praefix = host.anmeldeart === "ROOT" ? "" : "sudo -n ";
|
||||||
|
const ergebnis = await befehlAusfuehren(verbindung, `${praefix}sh -c ${shellQuote(skript)}`);
|
||||||
|
if (ergebnis.code !== 0 || !ergebnis.stdout.includes("SSHKV-ENTFERNT")) {
|
||||||
|
throw new VorgangsFehler(`${relativ} auf ${host.name} ließ sich nicht entfernen.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { ABHOLFRIST_MS } from "@/lib/schluessel";
|
||||||
|
import { protokollieren, type ProtokollEintrag } from "@/server/services/protokoll";
|
||||||
|
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||||
|
import { privateKeyLaden, privateKeyVerwerfen } from "@/worker/geheimnisse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Private Key abholen und loeschen (#014).
|
||||||
|
*
|
||||||
|
* Abholen: Der Worker oeffnet den Umschlag (nur er hat den Master-Key) und legt den Key mit
|
||||||
|
* SSHKV_APP_SECRET verschluesselt in `Vorgang.ergebnis` ab. Das Web holt ihn genau einmal ab
|
||||||
|
* und ueberschreibt das Ergebnis dabei; was nach ABHOLFRIST noch liegt, raeumt
|
||||||
|
* `abholungenAufraeumen()` weg – der Worker ruft es vor jeder Abholung und regelmaessig auf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ueberschreibt nicht abgeholte Private Keys, deren Vorgang laenger als ABHOLFRIST fertig ist.
|
||||||
|
* Liefert die Anzahl. Ein einziges UPDATE – kein Zeitfenster zwischen Lesen und Schreiben.
|
||||||
|
*/
|
||||||
|
export async function abholungenAufraeumen(jetzt: Date = new Date()): Promise<number> {
|
||||||
|
const grenze = new Date(jetzt.getTime() - ABHOLFRIST_MS);
|
||||||
|
return prisma.$executeRaw`
|
||||||
|
UPDATE "Vorgang" SET "ergebnis" = '{"verfallen": true}'::jsonb
|
||||||
|
WHERE "art" = 'PRIVATE_KEY_ABHOLEN'
|
||||||
|
AND "ergebnis" ? 'abholung'
|
||||||
|
AND "beendetAm" < ${grenze}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function anforderer(angefordertVonId: string | null): Promise<ProtokollEintrag["akteur"]> {
|
||||||
|
if (!angefordertVonId) return "System";
|
||||||
|
const benutzer = await prisma.benutzer.findUnique({
|
||||||
|
where: { id: angefordertVonId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
return benutzer ?? "System";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur Felder, die verraten, ob ein Key da ist – das Chiffrat liest allein geheimnisse.ts. */
|
||||||
|
async function schluesselLaden(schluesselId: string | null) {
|
||||||
|
if (!schluesselId) throw new VorgangsFehler("Der Vorgang nennt keinen Schlüssel.");
|
||||||
|
const schluessel = await prisma.schluessel.findUnique({
|
||||||
|
where: { id: schluesselId },
|
||||||
|
select: { id: true, name: true, slug: true, kategorie: true, fingerprint: true, masterKeyVersion: true },
|
||||||
|
});
|
||||||
|
if (!schluessel) throw new VorgangsFehler("Den Schlüssel gibt es nicht mehr.");
|
||||||
|
if (schluessel.masterKeyVersion === null) {
|
||||||
|
throw new VorgangsFehler("Zu diesem Schlüssel ist kein Private Key gespeichert.");
|
||||||
|
}
|
||||||
|
return schluessel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const privateKeyAbholenHandler: Handler = async ({ vorgang }) => {
|
||||||
|
await abholungenAufraeumen();
|
||||||
|
const schluessel = await schluesselLaden(vorgang.schluesselId);
|
||||||
|
const klartext = await privateKeyLaden(schluessel.id);
|
||||||
|
try {
|
||||||
|
// JS-Strings lassen sich nicht ueberschreiben; der Puffer wenigstens wird sofort genullt.
|
||||||
|
return {
|
||||||
|
ergebnis: { abholung: verschluesseln(klartext.toString("utf8")), fingerprint: schluessel.fingerprint },
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
klartext.fill(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const privateKeyLoeschenHandler: Handler = async ({ vorgang }) => {
|
||||||
|
const schluessel = await schluesselLaden(vorgang.schluesselId);
|
||||||
|
// Ohne Deployment-Key kaeme der Worker auf keinen Host mehr (Aussperrschutz).
|
||||||
|
if (schluessel.kategorie === "DEPLOYMENT") {
|
||||||
|
throw new VorgangsFehler("Den Private Key eines Deployment-Keys löscht sshkv nicht.");
|
||||||
|
}
|
||||||
|
if (!(await privateKeyVerwerfen(schluessel.id))) {
|
||||||
|
throw new VorgangsFehler("Der Private Key war bereits gelöscht.");
|
||||||
|
}
|
||||||
|
await protokollieren({
|
||||||
|
akteur: await anforderer(vorgang.angefordertVonId),
|
||||||
|
aktion: "privatekey.geloescht",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: schluessel.fingerprint,
|
||||||
|
details: { name: schluessel.name, slug: schluessel.slug },
|
||||||
|
});
|
||||||
|
return { ergebnis: { geloescht: true } };
|
||||||
|
};
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import type { Vorgang } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { entschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import {
|
||||||
|
publicKeyZeileMitKommentar,
|
||||||
|
schluesselTypVon,
|
||||||
|
vorgangEingabeSchema,
|
||||||
|
type SchluesselGeheimnis,
|
||||||
|
} from "@/lib/schluessel";
|
||||||
|
import { privateKeyLesen, SchluesselFormatFehler } from "@/lib/ssh/openssh";
|
||||||
|
import { HostFehler, istEindeutigkeitsFehler } from "@/server/services/hosts";
|
||||||
|
import { protokollieren, type ProtokollEintrag } from "@/server/services/protokoll";
|
||||||
|
import { bezuegePruefen, schluesselMitFingerprint } from "@/server/services/schluessel";
|
||||||
|
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||||
|
import { importiertenKeyVersiegeln, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schluessel erzeugen und ein Paar importieren (#012) – im Worker, weil nur er den Master-Key
|
||||||
|
* hat. Passphrase und importierter Private Key kommen mit SSHKV_APP_SECRET verschluesselt im
|
||||||
|
* Job (`geheim`); der Worker loescht den Job nach dem Lauf (src/worker/index.ts).
|
||||||
|
*
|
||||||
|
* Fehlermeldungen sind feste Saetze: aus `SchluesselFormatFehler` (die nie Eingaben
|
||||||
|
* zitieren) oder von hier. Nichts aus dem Private Key landet in `Vorgang.fehler`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wer den Vorgang angefordert hat – fuer Protokoll und `erstelltVonId`. */
|
||||||
|
async function anforderer(vorgang: Vorgang): Promise<ProtokollEintrag["akteur"]> {
|
||||||
|
if (!vorgang.angefordertVonId) return "System";
|
||||||
|
const benutzer = await prisma.benutzer.findUnique({
|
||||||
|
where: { id: vorgang.angefordertVonId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
return benutzer ?? "System";
|
||||||
|
}
|
||||||
|
|
||||||
|
function geheimnisLesen(geheim: string | undefined): SchluesselGeheimnis {
|
||||||
|
if (!geheim) return {};
|
||||||
|
try {
|
||||||
|
const wert = JSON.parse(entschluesseln(geheim)) as unknown;
|
||||||
|
if (!wert || typeof wert !== "object") throw new Error("kein Objekt");
|
||||||
|
const { privateKey, passphrase } = wert as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
...(typeof privateKey === "string" ? { privateKey } : {}),
|
||||||
|
...(typeof passphrase === "string" && passphrase ? { passphrase } : {}),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new VorgangsFehler("Die verschlüsselten Angaben des Vorgangs sind unlesbar. Bitte erneut starten.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eingabe pruefen, Bezuege nachsehen – Fachfehler werden zu Meldungen des Vorgangs. */
|
||||||
|
async function eingabeAufloesen(vorgang: Vorgang) {
|
||||||
|
const ergebnis = vorgangEingabeSchema.safeParse(vorgang.eingabe);
|
||||||
|
if (!ergebnis.success) throw new VorgangsFehler("Die Angaben zum Schlüssel sind unvollständig.");
|
||||||
|
const eingabe = ergebnis.data;
|
||||||
|
try {
|
||||||
|
const { ablaufAm } = await bezuegePruefen({
|
||||||
|
personId: eingabe.personId,
|
||||||
|
vorlageId: eingabe.vorlageId,
|
||||||
|
ablaufAm: eingabe.ablaufAm ? new Date(eingabe.ablaufAm) : null,
|
||||||
|
});
|
||||||
|
return { ...eingabe, ablaufAm };
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof HostFehler) throw new VorgangsFehler(fehler.message);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schonVerwaltet(vorhanden: { name: string }): VorgangsFehler {
|
||||||
|
return new VorgangsFehler(`Dieser Schlüssel wird bereits verwaltet: „${vorhanden.name}“.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Vorgang soll auf „seinen“ Schluessel zeigen – fuer Verlauf und Oberflaeche. */
|
||||||
|
async function vorgangVerknuepfen(vorgangId: string, schluesselId: string): Promise<void> {
|
||||||
|
await prisma.vorgang.update({ where: { id: vorgangId }, data: { schluesselId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) => {
|
||||||
|
const eingabe = await eingabeAufloesen(vorgang);
|
||||||
|
const { passphrase } = geheimnisLesen(geheim);
|
||||||
|
if (eingabe.mitPassphrase && !passphrase) {
|
||||||
|
throw new VorgangsFehler("Die Passphrase fehlt – bitte erneut erzeugen.");
|
||||||
|
}
|
||||||
|
const akteur = await anforderer(vorgang);
|
||||||
|
const typ = eingabe.typ ?? "ED25519";
|
||||||
|
const kommentar = eingabe.kommentar ?? `sshkv:${eingabe.slug}`;
|
||||||
|
|
||||||
|
const neu = await schluesselpaarVersiegeltErzeugen(typ, kommentar, passphrase);
|
||||||
|
try {
|
||||||
|
await prisma.schluessel.create({
|
||||||
|
data: {
|
||||||
|
id: neu.id,
|
||||||
|
name: eingabe.name,
|
||||||
|
slug: eingabe.slug,
|
||||||
|
typ,
|
||||||
|
kategorie: "DIENST",
|
||||||
|
publicKey: neu.publicKeyZeile,
|
||||||
|
fingerprint: neu.fingerprint,
|
||||||
|
kommentar,
|
||||||
|
tags: eingabe.tags,
|
||||||
|
ablaufAm: eingabe.ablaufAm,
|
||||||
|
personId: eingabe.personId,
|
||||||
|
vorlageId: eingabe.vorlageId,
|
||||||
|
erstelltVonId: akteur === "System" ? null : akteur.id,
|
||||||
|
passphraseGeschuetzt: Boolean(passphrase),
|
||||||
|
...neu.versiegelt,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) {
|
||||||
|
throw new VorgangsFehler(
|
||||||
|
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
await vorgangVerknuepfen(vorgang.id, neu.id);
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "schluessel.erzeugt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: neu.fingerprint,
|
||||||
|
details: {
|
||||||
|
name: eingabe.name,
|
||||||
|
slug: eingabe.slug,
|
||||||
|
typ,
|
||||||
|
kategorie: "DIENST",
|
||||||
|
passphraseGeschuetzt: Boolean(passphrase),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const schluesselImportierenHandler: Handler = async ({ vorgang, geheim }) => {
|
||||||
|
const eingabe = await eingabeAufloesen(vorgang);
|
||||||
|
const { privateKey, passphrase } = geheimnisLesen(geheim);
|
||||||
|
if (!privateKey) throw new VorgangsFehler("Der Private Key fehlt – bitte den Import erneut starten.");
|
||||||
|
const akteur = await anforderer(vorgang);
|
||||||
|
|
||||||
|
let info;
|
||||||
|
try {
|
||||||
|
// Die Passphrase dient nur dem Lesen von Public Key und Fingerprint und wird danach
|
||||||
|
// verworfen; gespeichert wird der Key so, wie er kam (Freigabe Frage 6).
|
||||||
|
info = privateKeyLesen(privateKey, passphrase);
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof SchluesselFormatFehler) throw new VorgangsFehler(fehler.message);
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const vorhanden = await schluesselMitFingerprint(info.fingerprint);
|
||||||
|
if (vorhanden) throw schonVerwaltet(vorhanden);
|
||||||
|
|
||||||
|
const algorithmus = info.typ === "ED25519" ? "ssh-ed25519" : "ssh-rsa";
|
||||||
|
const typ = schluesselTypVon(algorithmus, info.bits);
|
||||||
|
const kommentar = eingabe.kommentar ?? (info.kommentar || `sshkv:${eingabe.slug}`);
|
||||||
|
const { id, versiegelt } = await importiertenKeyVersiegeln(info.fingerprint, privateKey);
|
||||||
|
try {
|
||||||
|
await prisma.schluessel.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
name: eingabe.name,
|
||||||
|
slug: eingabe.slug,
|
||||||
|
typ,
|
||||||
|
kategorie: "DIENST",
|
||||||
|
publicKey: publicKeyZeileMitKommentar(info.publicKeyZeile, kommentar),
|
||||||
|
fingerprint: info.fingerprint,
|
||||||
|
kommentar,
|
||||||
|
tags: eingabe.tags,
|
||||||
|
ablaufAm: eingabe.ablaufAm,
|
||||||
|
personId: eingabe.personId,
|
||||||
|
vorlageId: eingabe.vorlageId,
|
||||||
|
erstelltVonId: akteur === "System" ? null : akteur.id,
|
||||||
|
passphraseGeschuetzt: info.verschluesselt,
|
||||||
|
...versiegelt,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
if (istEindeutigkeitsFehler(fehler)) {
|
||||||
|
const jetzt = await schluesselMitFingerprint(info.fingerprint);
|
||||||
|
if (jetzt) throw schonVerwaltet(jetzt);
|
||||||
|
throw new VorgangsFehler(
|
||||||
|
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut importieren.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
await vorgangVerknuepfen(vorgang.id, id);
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "schluessel.importiert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: info.fingerprint,
|
||||||
|
details: {
|
||||||
|
name: eingabe.name,
|
||||||
|
slug: eingabe.slug,
|
||||||
|
typ,
|
||||||
|
kategorie: "DIENST",
|
||||||
|
art: "paar",
|
||||||
|
passphraseGeschuetzt: info.verschluesselt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ergebnis: { schluesselId: id, fingerprint: info.fingerprint } };
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { shellQuote } from "@/lib/shell";
|
||||||
|
import { privatPfadPruefen } from "@/lib/ssh/ssh-config";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||||
|
import { hostVerbinden } from "@/worker/ssh/host";
|
||||||
|
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindungstest Host A → Host B (#020): auf A als Zielbenutzer
|
||||||
|
* `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes <alias> true`.
|
||||||
|
*
|
||||||
|
* BatchMode verhindert jede Rueckfrage (Passwort, Passphrase), StrictHostKeyChecking jede
|
||||||
|
* stille Annahme eines Host-Keys – der Test beweist also genau das, was ein Cronjob auf A
|
||||||
|
* spaeter auch braucht.
|
||||||
|
*/
|
||||||
|
export const verbindungstestHandler: Handler = async ({ vorgang }) => {
|
||||||
|
const verwendungId = (vorgang.eingabe as { privatVerwendungId?: unknown } | null)?.privatVerwendungId;
|
||||||
|
if (typeof verwendungId !== "string") throw new VorgangsFehler("Kein Private-Key-Ziel angegeben.");
|
||||||
|
const verwendung = await prisma.privateVerwendung.findUnique({
|
||||||
|
where: { id: verwendungId },
|
||||||
|
include: { host: true, schluessel: { select: { id: true, fingerprint: true } } },
|
||||||
|
});
|
||||||
|
if (!verwendung || verwendung.zustand !== "AUSGEROLLT") {
|
||||||
|
throw new VorgangsFehler("Der Private Key ist auf diesem Host (noch) nicht ausgerollt.");
|
||||||
|
}
|
||||||
|
const konfiguration = verwendung.sshConfig as {
|
||||||
|
alias?: string;
|
||||||
|
hostName?: string;
|
||||||
|
benutzer?: string;
|
||||||
|
port?: number;
|
||||||
|
} | null;
|
||||||
|
const ziel = konfiguration?.alias
|
||||||
|
? shellQuote(konfiguration.alias)
|
||||||
|
: (() => {
|
||||||
|
throw new VorgangsFehler("Für den Test braucht die Zuweisung einen ~/.ssh/config-Eintrag (Alias).");
|
||||||
|
})();
|
||||||
|
privatPfadPruefen(verwendung.pfad);
|
||||||
|
|
||||||
|
const ssh = `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10 ${ziel} true`;
|
||||||
|
const praefix = verwendung.host.anmeldeart === "ROOT" ? "" : "sudo -n ";
|
||||||
|
const befehl = `${praefix}runuser -u ${shellQuote(verwendung.zielbenutzer)} -- sh -c ${shellQuote(`cd ~ && ${ssh}`)}`;
|
||||||
|
|
||||||
|
const verbindung = await hostVerbinden(verwendung.host);
|
||||||
|
let ergebnis;
|
||||||
|
try {
|
||||||
|
ergebnis = await befehlAusfuehren(verbindung, befehl);
|
||||||
|
} finally {
|
||||||
|
verbindung.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const erfolg = ergebnis.code === 0;
|
||||||
|
// Die letzte Zeile von ssh auf stderr ist die aussagekraeftige (Permission denied, Host key …).
|
||||||
|
const meldung = ergebnis.stderr.trim().split("\n").filter(Boolean).pop()?.slice(0, 300) ?? "";
|
||||||
|
await protokollieren({
|
||||||
|
akteur: "System",
|
||||||
|
aktion: "verbindungstest",
|
||||||
|
ergebnis: erfolg ? "ERFOLG" : "FEHLER",
|
||||||
|
hostName: verwendung.host.name,
|
||||||
|
zielbenutzer: verwendung.zielbenutzer,
|
||||||
|
fingerprint: verwendung.schluessel.fingerprint,
|
||||||
|
details: { alias: konfiguration?.alias ?? null, meldung },
|
||||||
|
});
|
||||||
|
if (!erfolg) {
|
||||||
|
throw new VorgangsFehler(
|
||||||
|
`Verbindung von ${verwendung.zielbenutzer}@${verwendung.host.name} nach ${konfiguration?.alias} fehlgeschlagen: ${meldung || `Exit-Code ${ergebnis.code}`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prisma.schluessel.update({
|
||||||
|
where: { id: verwendung.schluessel.id },
|
||||||
|
data: { zuletztVerwendetAm: new Date() },
|
||||||
|
});
|
||||||
|
return { ergebnis: { erfolg: true, alias: konfiguration?.alias ?? null } };
|
||||||
|
};
|
||||||
@@ -126,13 +126,16 @@ export type NeuerSchluessel = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Erzeugt ein Schluesselpaar und versiegelt den Private Key sofort. Die ID wird hier
|
* Erzeugt ein Schluesselpaar und versiegelt den Private Key sofort. Die ID wird hier
|
||||||
* vergeben, weil sie in die Zusatzdaten des Umschlags eingeht.
|
* vergeben, weil sie in die Zusatzdaten des Umschlags eingeht. Mit `passphrase` liegt im
|
||||||
|
* Umschlag der bereits passphrasengeschuetzte OpenSSH-Key; die Passphrase selbst wird nie
|
||||||
|
* gespeichert (Freigabe Frage 6, #012).
|
||||||
*/
|
*/
|
||||||
export async function schluesselpaarVersiegeltErzeugen(
|
export async function schluesselpaarVersiegeltErzeugen(
|
||||||
typ: ErzeugbarerSchluesseltyp,
|
typ: ErzeugbarerSchluesseltyp,
|
||||||
kommentar: string,
|
kommentar: string,
|
||||||
|
passphrase?: string,
|
||||||
): Promise<NeuerSchluessel> {
|
): Promise<NeuerSchluessel> {
|
||||||
const paar = await schluesselpaarErzeugen(typ, { kommentar });
|
const paar = await schluesselpaarErzeugen(typ, { kommentar, ...(passphrase ? { passphrase } : {}) });
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -142,6 +145,35 @@ export async function schluesselpaarVersiegeltErzeugen(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versiegelt einen importierten Private Key (#012) so, wie er kam – ein
|
||||||
|
* passphrasengeschuetzter Key bleibt es. Die ID wird hier vergeben (Zusatzdaten).
|
||||||
|
*/
|
||||||
|
export async function importiertenKeyVersiegeln(
|
||||||
|
fingerprint: string,
|
||||||
|
privateKeyOpenssh: string,
|
||||||
|
): Promise<{ readonly id: string; readonly versiegelt: VersiegelterKey }> {
|
||||||
|
const id = randomUUID();
|
||||||
|
return { id, versiegelt: await privateKeyVersiegeln(id, fingerprint, privateKeyOpenssh) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwirft den gespeicherten Private Key endgueltig (#014): Chiffrat, Data-Key und Version
|
||||||
|
* auf null, Zeitpunkt festhalten. Liefert false, wenn keiner (mehr) gespeichert war.
|
||||||
|
*/
|
||||||
|
export async function privateKeyVerwerfen(schluesselId: string): Promise<boolean> {
|
||||||
|
const ergebnis = await prisma.schluessel.updateMany({
|
||||||
|
where: { id: schluesselId, privatVerschluesselt: { not: null } },
|
||||||
|
data: {
|
||||||
|
privatVerschluesselt: null,
|
||||||
|
dekVerschluesselt: null,
|
||||||
|
masterKeyVersion: null,
|
||||||
|
privatGeloeschtAm: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ergebnis.count === 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stellt beim Start des Workers sicher, dass es einen Deployment-Key der Instanz gibt
|
* Stellt beim Start des Workers sicher, dass es einen Deployment-Key der Instanz gibt
|
||||||
* (#006). Gibt die ID zurueck. Gleichzeitige Starts scheitern am partiellen Index
|
* (#006). Gibt die ID zurueck. Gleichzeitige Starts scheitern am partiellen Index
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||||
|
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
||||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||||
|
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
||||||
|
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
||||||
|
import { verbindungstestHandler } from "@/worker/ablaeufe/verbindungstest";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Ausrollen (#016), Abgleich (#021) …
|
* Welche Vorgangsart wer ausfuehrt. Waechst mit den Issues: Abgleich (#021) …
|
||||||
*/
|
*/
|
||||||
export const HANDLER: HandlerTabelle = {
|
export const HANDLER: HandlerTabelle = {
|
||||||
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
HOSTKEY_ABRUFEN: hostKeyAbrufenHandler,
|
||||||
ERREICHBARKEIT: erreichbarkeitHandler,
|
ERREICHBARKEIT: erreichbarkeitHandler,
|
||||||
BOOTSTRAP: bootstrapHandler,
|
BOOTSTRAP: bootstrapHandler,
|
||||||
|
TROCKENLAUF: trockenlaufHandler,
|
||||||
|
AUSROLLEN: ausrollenHandler,
|
||||||
|
SCHLUESSEL_ERZEUGEN: schluesselErzeugenHandler,
|
||||||
|
SCHLUESSEL_IMPORTIEREN: schluesselImportierenHandler,
|
||||||
|
PRIVATE_KEY_ABHOLEN: privateKeyAbholenHandler,
|
||||||
|
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
||||||
|
VERBINDUNGSTEST: verbindungstestHandler,
|
||||||
};
|
};
|
||||||
|
|||||||