29 Commits
Author SHA1 Message Date
florian.wilde 2dad8e5631 Merge branch 'phase/release-0.3.0' 2026-10-12 11:25:17 +02:00
florian.wildeandClaude Opus 5.5 2c658b1223 chore(release): 0.3.0 - Phase 3 abgeschlossen
Host-zu-Host-Assistent, Zielkonten mit sudoers, Vorlagen. CHANGELOG
und DEVLOG nachgetragen.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:16:31 +02:00
florian.wilde bdf12b05e7 Merge branch 'phase/release-0.2.0' 2026-10-12 11:05:59 +02:00
florian.wildeandClaude Opus 5.5 0a58f6ce32 chore(release): 0.2.0 - Phase 2 abgeschlossen
Schluessel, Personen, Private Key anzeigen, authorized_keys-Parser und
Ausrollen von Public Keys; vorgezogen das Ausrollen von Private Keys
mit ssh-config und known_hosts. CHANGELOG und DEVLOG nachgetragen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:05:57 +02:00
florian.wilde 1dc39671c2 Merge branch 'phase/016-017-schluesselseite' 2026-10-12 11:05:07 +02:00
florian.wildeandClaude Opus 5.5 12603eb145 feat(schluessel): Zuweisen und Ausrollen auf der Schluesselseite (#017)
Auf der Seite eines Schluessels: Public Key einem Host oder einer
Gruppe zuweisen, Private Key (nur Admin) auf einen Quell-Host legen -
mit ~/.ssh/config-Eintrag und dem Host-Key des Ziels in known_hosts -,
Dry-Run fuer alle Ziele, anwenden und die Verbindung von A nach B
testen. Der Kern-Anwendungsfall laeuft damit im Browser durch (E2E).

Der Dry-Run nimmt Private-Key-Ziele nur fuer Admins auf: sonst koennte
ein Operator einen vorbereiteten Private Key ausrollen lassen. Die
Vorschau zeigt je Datei die Aktion; der Inhalt eines Private Keys
erreicht den Browser nie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:05:03 +02:00
florian.wilde 68136e8f07 Merge branch 'phase/019-vorlagen' 2026-10-12 10:54:31 +02:00
florian.wilde 58e50f14c2 Merge branch 'phase/017-private-key-ausrollen'
# Conflicts:
#	src/worker/handler.ts
2026-10-12 10:54:28 +02:00
florian.wilde 3f72a1c7bf Merge branch 'phase/018-benutzer-sudoers' 2026-10-12 10:54:12 +02:00
florian.wilde 445afe3a9b Merge branch 'phase/012-014' (Schluessel, Personen, Private Key)
# Conflicts:
#	src/components/vorgang/useVorgang.ts
#	src/worker/handler.ts
2026-10-12 10:54:09 +02:00
florian.wildeandClaude Opus 5.5 3e78ed55e4 feat(vorlagen): Platzhalter, Pruefung und Service fuer Vorlagen (#019)
Vorlagen werden beim Speichern so geprueft, wie sie spaeter benutzt
werden: mit probeweise aufgeloesten Platzhaltern. Ein unbekannter
Platzhalter ist ein Fehler, kein leerer Text - sonst stuende from=""
in authorized_keys. Werte mit Anfuehrungszeichen, Kommas oder Leerraum
koennen keine weiteren Optionen einschleusen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:52:30 +02:00
florian.wildeandClaude Opus 5.5 2b6f826ce0 docs(anleitung): Screenshots Schluessel, Personen, Self-Service (#014)
Neue Aufnahmen fuer Schluesselliste, Neuer Schluessel, Schluesseldetail
(echter Demo-Key statt Prototyp), Personen und Meine Schluessel; die
uebrigen neu, weil die Navigation jetzt "Meine Schluessel" zeigt.
Aufgenommen gegen den gebauten Stand mit SSHKV_SEED_DEMO=1, 390 px ohne
seitliches Scrollen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:51:17 +02:00
florian.wildeandClaude Opus 5.5 0bb62450f3 feat(schluessel): Private Key anzeigen, herunterladen, loeschen (#014)
Admins koennen einen gespeicherten Private Key nach frischer
Bestaetigung einmalig anzeigen oder als Datei <slug> herunterladen und
ihn endgueltig aus SSHKeyVault loeschen.

Warum so:
- 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. Das Web holt ihn genau einmal
  ab: ein einziges UPDATE unter Zeilensperre liest und ueberschreibt.
- Nicht abgeholte Keys verfallen nach zwei Minuten: Worker-Takt alle
  30 s, Aufraeumen vor jeder Abholung, und das Web gibt aeltere nicht
  mehr heraus. Die Status-Schnittstelle zeigt nur "bereit".
- Download nur per POST (ein GET-Link oder Vorabruf wuerde die
  Abholung verbrauchen), no-store, Herkunft per Sec-Fetch-Site, weil
  no-referrer bei Formular-POSTs Origin: null sendet.
- Loeschen laeuft als Worker-Vorgang, damit nur geheimnisse.ts die
  Chiffrat-Felder anfasst; eigenes Recht privateKeyLoeschen mit
  Bestaetigung. Deployment-Keys gibt sshkv weder heraus noch loescht es
  sie.
- Protokoll: angefordert, angezeigt, heruntergeladen, geloescht - mit
  Fingerprint, nie mit Key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:51:04 +02:00
florian.wildeandClaude Opus 5.5 61a52d4538 feat(ausrollen): Verbindungstest von Host A nach Host B (#020)
Auf A als Zielbenutzer ssh mit BatchMode und StrictHostKeyChecking:
keine Rueckfrage, keine stille Annahme eines Host-Keys. Der Test
beweist damit genau das, was ein Cronjob auf A spaeter braucht. Die
letzte Meldung von ssh landet in Vorgang und Protokoll; ein Erfolg
setzt die letzte Verwendung des Schluessels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:50:43 +02:00
florian.wildeandClaude Opus 5.5 edc38d0c27 feat(ausrollen): Private Key mit ssh-config und known_hosts (#017)
Der Kern-Anwendungsfall im Worker: Private Key auf Host A, Public Key
auf Host B, dazu ein Block in ~/.ssh/config von A und der vertraute
Host-Key von B in known_hosts - danach meldet sich A per Alias bei B
an, ohne Rueckfrage zum Host-Key (Integrationstest gegen zwei echte
sshd).

Gleiches Muster wie bei Public Keys: Plan je Datei mit Hashes, dann
genau dieser Plan. Der Inhalt eines Private Keys steht nie im Plan
oder Protokoll, eine fremde Datei am Zielpfad wird nie ueberschrieben,
und von einem alten Private Key bleibt keine Sicherungskopie liegen.
Der config-Block steht am Anfang, weil ssh den ersten Treffer nimmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:49:28 +02:00
florian.wildeandClaude Opus 5.5 b5b43f41f6 feat(personen): Personen und Self-Service Meine Schluessel (#013)
Personen lassen sich anlegen, bearbeiten und archivieren; ihr Detail
zeigt ihre Schluessel. Unter "Meine Schluessel" hinterlegt jede Rolle
eigene Public Keys (Freigabe Frage 4), wirksam erst nach Zuweisung
durch einen Operator oder Admin.

Warum so:
- Die eigene Person entsteht beim ersten Aufruf oder wird verknuepft:
  per benutzerId, sonst per E-Mail - aber nur, wenn der OIDC-Anbieter
  die Adresse bestaetigt hat. Dafuer merkt sich Benutzer jetzt
  emailBestaetigt (Migration); sonst koennte jemand mit selbst
  gewaehlter Adresse die Keys einer fremden Person sehen.
- Die Self-Service-Services nehmen nur den angemeldeten Benutzer und
  leiten die Person selbst ab; fremde Schluessel-IDs gelten als nicht
  vorhanden.
- Entfernen nur fuer eigene, selbst hinterlegte Keys ohne Verwendung,
  bedingt geloescht, damit eine gleichzeitige Zuweisung nicht per
  Cascade verschwindet.
- Archivieren statt Loeschen: wer bis wann welchen Key hatte, bleibt
  nachvollziehbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:55 +02:00
florian.wildeandClaude Opus 5.5 7b6323a6a4 feat(zielkonten): Benutzer anlegen und sudoers pflegen (#018)
Zielbenutzer wie deploy legt sshkv bei Bedarf an, mit ~/.ssh und
optional einer sudoers-Datei: keine Rechte, bestimmte Befehle oder
voll. Die Datei entsteht unter einem Namen, den sudo ignoriert, und
wird erst nach visudo -c aktiv. Eine fremde Datei gleichen Namens
ueberschreibt sshkv nie, Systemkonten wie root oder sshkv fasst es
nicht an.

Befehle muessen absolute Pfade sein; Zeichen mit Sonderbedeutung in
sudoers sind ausgeschlossen, damit die Liste so wirkt, wie sie aussieht.

Vorgang und Oberflaeche folgen mit #019/#020.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:38:22 +02:00
florian.wilde c2b8004ade Merge branch 'phase/016-public-key-ausrollen' 2026-10-12 10:36:22 +02:00
florian.wildeandClaude Opus 5.5 263143134f feat(schluessel): erzeugen, importieren, exportieren (#012)
Schluessel werden jetzt verwaltet statt nur im Prototyp gezeigt: Liste
mit Filter, Anlegen (erzeugen oder importieren), Detail nach dem
Prototyp aus #002, Bearbeiten der Metadaten und Download <slug>.pub.

Warum so:
- Erzeugen und Import eines Paars laufen als Vorgang im Worker, weil
  nur er den Master-Key hat. Passphrase und importierter Private Key
  reisen wie das Bootstrap-Passwort mit SSHKV_APP_SECRET verschluesselt
  im Job, nie in Vorgang.eingabe; der Worker loescht den Job danach.
- Ein reiner Public Key ist kein Geheimnis und wird direkt im Web
  angelegt, mit verstaendlicher Meldung bei einem Duplikat.
- Das Web liest nie das Chiffrat: ob ein Private Key gespeichert ist,
  verraet masterKeyVersion (per CHECK an das Chiffrat gekoppelt). Ein
  struktureller Test verlangt fuer jede zeilenliefernde Abfrage auf
  Schluessel ausserhalb des Workers ein festes select.
- Migration: Vorgangsarten fuer Erzeugen, Import und (fuer #014) das
  Loeschen des Private Keys; CHECKs fuer den Slug (landet in
  Dateinamen) und "geloescht nur ohne Chiffrat".
- Platzhalter PublicAusrollenAktion/PrivateAusrollenAktion fuer #016/#017.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:29:52 +02:00
florian.wildeandClaude Opus 5.5 8056b8a0c9 test(ausrollen): Dry-Run und Ausrollen im Browser (#016)
Gebauter Stand mit Worker gegen das Testziel: Host einrichten, Dry-Run
zeigt das Diff, auf dem Host aendert sich noch nichts, nach dem
Anwenden steht der Key mit seinen Einschraenkungen im Block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:21:54 +02:00
florian.wildeandClaude Opus 5.5 30f271b23a feat(hosts): Schluessel auf dem Host anzeigen und ausrollen (#016)
Die Hostseite listet, welche Public und Private Keys sshkv dort
verwaltet - direkt oder ueber eine Hostgruppe - mit Zustand als Punkt
und Text. Ein Dry-Run je Host zeigt das Diff aller Benutzer dieses
Hosts und rollt nach Bestaetigung aus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:19:27 +02:00
florian.wildeandClaude Opus 5.5 f66d87ad11 feat(ausrollen): Public Keys mit Trockenlauf und Backup ausrollen (#016)
Zuweisungen von Public Keys an Hosts oder Hostgruppen, ein Trockenlauf
mit Diff je Ziel und das Ausrollen genau dieses Plans. Hat sich die
Datei oder das Soll seit dem Trockenlauf geaendert, bricht das Ziel
ab: es wird nie etwas anderes geschrieben, als jemand gesehen hat.

Auf dem Host geschieht alles als Zielbenutzer, mit Sicherung, Temp-
Datei, mv und 0700/0600; der Hash wird dort erneut geprueft. Der
Deployment-Key gehoert immer zum Soll des Anmeldebenutzers.

Integrationstests gegen echtes sshd, die Oberflaechenbausteine folgen
mit der Schluesselseite aus #012.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:17:35 +02:00
florian.wilde 1358055e40 Merge branch 'phase/015-authorized-keys-parser' 2026-10-12 10:10:58 +02:00
florian.wildeandClaude Opus 5.5 19edd20d72 feat(ssh): authorized_keys lesen und verwalteten Block setzen (#015)
Reine Funktionen fuer Dry-Run und Ausrollen. Alles ausserhalb des
Blocks bleibt bytegenau stehen, auch CRLF, BOM und kaputte Zeilen;
der Block entsteht deterministisch aus dem Soll. Ein beschaedigter
Block wird nie automatisch ueberschrieben, weil genau dort fremde
Zeilen verschwinden koennten.

Verwaltete Keys erkennt sshkv am Fingerprint, nicht am Kommentar. Der
Deployment-Key kann nicht aus dem Block fallen (Aussperrschutz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:10:54 +02:00
148 changed files with 12789 additions and 359 deletions
+35
View File
@@ -15,6 +15,41 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.3.0] – 2026-10-12
Phase 3: Host-zu-Host – Private Keys, Zielkonten, Vorlagen und Assistent.
### Hinzugefügt
- Host-zu-Host-Assistent: Vorlage wählen, Host A und B, Zielbenutzer, Einschränkungen und `~/.ssh/config`-Alias, Fingerprint von B bestätigen, Dry-Run, ausrollen, Verbindung testen – in einem Durchgang (#020)
- Zielbenutzer (z. B. `deploy`) auf dem Ziel anlegen, mit sudoers-Datei für bestimmte Befehle oder vollen Zugriff; Vorschau vor dem Anlegen, Prüfung mit `visudo -c` (#018)
- Vorlagen verwalten: eingebaute „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“, eigene anlegen und archivieren, Live-Vorschau der Platzhalter und des sudoers-Inhalts (#019)
- Übersicht verlinkt den Assistenten
### Sicherheit
- Neue Benutzer mit sudo nur durch Admins mit frischer Bestätigung
- sshkv überschreibt keine fremde sudoers-Datei und fasst Systemkonten nicht an; ein Widerruf löscht nur Benutzer, die sshkv selbst angelegt hat
- Platzhalter-Werte können keine zusätzlichen authorized_keys-Optionen einschleusen
## [0.2.0] – 2026-10-12
Phase 2: Schlüssel, Personen und Ausrollen von Public Keys – dazu vorgezogen aus Phase 3 das Ausrollen von Private Keys.
### 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.
+48 -2
View File
@@ -2,8 +2,8 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt (Wandzeit):** 1 h 25 min **Gesamt (Wandzeit):** 2 h 55 min
**Summe der Issue-Zeiten:** 4 h 23 min – Sub-Agenten arbeiteten parallel an #002, #006, #008 und #011. **Summe der Issue-Zeiten:** 8 h 23 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014 und #019 (Oberfläche).
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -19,10 +19,21 @@
| #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 | 0 h 30 min |
| #019 Vorlagen | 0 h 20 min |
| #020 Host-zu-Host-Assistent | 0 h 50 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
| v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen** | v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen**
| v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen**
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
--- ---
@@ -55,3 +66,38 @@
- **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.
## 2026-10-12 · Sitzung 3 · 11:06–11:36
**Issue:** #018–#020
**Version:** 0.3.0
### Erledigt
- #019 Vorlagen-Oberfläche (Liste, Formular, Live-Vorschau) von einem Sub-Agenten, gemergt
- #018 Zielkonten als Vorgänge mit Migration, Herkunft gemerkt; Integrationstest anlegen → entfernen
- #020 Host-zu-Host-Assistent mit Vorlagen, wiederaufnehmbarer Ausführung und Verbindungstest; E2E mit „Deployment-Zugang“
- Stand: 423 Unit-/DB-Tests, 31 Integrationstests, 16 E2E-Abläufe grün
### Entscheidungen
- **Migration auf Ortszeit-Reihenfolge umbenannt** (`20261012110000`): Prisma vergibt UTC-Zeitstempel, die Migrationen des Agenten trugen Ortszeit – sonst stünde die neue vor älteren.
- **Bestätigung im neuen Tab:** Der Assistent hält seine Eingaben im Speicher; eine Umleitung nach `/bestaetigen` hätte sie gelöscht.
- **Ausführung des Assistenten als Kette im Klick** statt als Hook-Zustandsmaschine – einfacher, und erledigte Schritte lassen sich beim Fortsetzen überspringen.
- **Prozesse stoppen nur über ein Skript, das Programm und Arbeitsverzeichnis prüft:** Zweimal hatte ein zu breites Suchmuster die eigene Shell getroffen.
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+45 -8
View File
@@ -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`.
+26 -5
View File
@@ -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).
+37 -5
View File
@@ -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.
+14 -7
View File
@@ -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).
+12 -6
View File
@@ -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.
+12 -6
View File
@@ -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.
+10 -5
View File
@@ -1,20 +1,25 @@
# #018 Zielbenutzer anlegen und sudoers # #018 Zielbenutzer anlegen und sudoers
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** backend, sicherheit **Labels:** backend, sicherheit
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 30 min
## Ziel ## Ziel
Benutzer (z. B. `deploy`) anlegen, optional sudoers mit Vorschau und `visudo -c`. Benutzer (z. B. `deploy`) anlegen, optional sudoers mit Vorschau und `visudo -c`.
## Aufgaben ## Aufgaben
- [ ] Auswahl keine/bestimmte Befehle/voll - [x] Auswahl keine/bestimmte Befehle/voll
- [ ] Entfernen beim Widerruf - [x] Entfernen beim Widerruf
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Integrationstest - [x] Integrationstest
## Abhängigkeiten ## Abhängigkeiten
#016 #016
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **sudoers** entsteht als `/etc/sudoers.d/.sshkv.XXXX` (Punkt im Namen: sudo ignoriert die Datei), wird mit `visudo -cf` geprüft und erst dann nach `/etc/sudoers.d/sshkv-<benutzer>` verschoben. Kopfzeile kennzeichnet sshkv-Dateien; eine fremde Datei gleichen Namens wird nie überschrieben.
- **Befehle:** absoluter Pfad, keine Zeichen mit Sonderbedeutung in sudoers (`, : = \`, Wildcards) – die Liste soll wirken, wie sie aussieht.
- **Geschützte Konten** (root, sshkv, Systemkonten) verwaltet sshkv nie als Zielkonto.
- **Herkunft gemerkt:** `vonSshkvAngelegt` – nur dann löscht ein Entfernen den Benutzer samt Home, sonst nur die sudoers-Datei.
- Vorgänge `ZIELKONTO_ANLEGEN`/`ZIELKONTO_ENTFERNEN` (Migration `20261012110000`), Recht `zielkontenVerwalten` = Admin mit frischer Bestätigung. Oberfläche im Assistenten (#020) mit sudoers-Vorschau.
+16 -3
View File
@@ -1,5 +1,5 @@
# #019 Vorlagen # #019 Vorlagen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** backend, ui **Labels:** backend, ui
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** –
@@ -8,12 +8,25 @@
Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen. Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen.
## Aufgaben ## Aufgaben
- [ ] Platzhalter `{{quellHostIp}}` usw. - [x] Platzhalter `{{quellHostIp}}` usw.
- [x] Oberfläche: Liste `/vorlagen` (alle lesen), Anlegen/Bearbeiten/Archivieren nur Admin
- [x] Live-Vorschau der aufgelösten Optionen, des Alias und der sudoers-Datei
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Vorlage füllt Assistent vor - [ ] Vorlage füllt Assistent vor (folgt mit dem Assistenten, #020)
## Abhängigkeiten ## Abhängigkeiten
#017, #018 #017, #018
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Vorlagen pflegt nur, wer `einstellungenVerwalten` darf (Admin); lesen darf jede Rolle, weil
Vorlagen keine Geheimnisse enthalten und Operatoren sie im Assistenten sehen.
- Die Vorschau im Formular ruft dieselben reinen Funktionen wie der Server
(`platzhalterErsetzen`, `optionenPruefen`, `sudoersInhalt`) mit eigenen Beispielwerten auf;
Fehler werden dort nur angezeigt, verbindlich prüft der Server beim Speichern.
- Das Befehlsfeld wird bei anderem sudo-Modus nur ausgeblendet, nicht entfernt – so gehen
eingegebene Befehle beim Umschalten nicht verloren; der Service verwirft sie ohnehin.
- Die sudoers-Vorschau bricht nicht um: sudoers ist zeilenbasiert, ein optischer Umbruch sähe
wie eine zweite Regel aus.
- Eingebaute Vorlagen lassen sich bearbeiten, aber nicht archivieren; ihre Bearbeiten-Seite
zeigt deshalb keinen Archivieren-Knopf.
+12 -6
View File
@@ -1,21 +1,27 @@
# #020 Host-zu-Host-Assistent und Verbindungstest # #020 Host-zu-Host-Assistent und Verbindungstest
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** ui, backend **Labels:** ui, backend
**Geschätzt:** 4 h **Tatsächlich:** – **Geschätzt:** 4 h **Tatsächlich:** 0 h 50 min
## Ziel ## Ziel
Geführter Ablauf: Schlüssel erzeugen, Private auf A, Public auf B, Host-Key von B in known_hosts von A, Verbindungstest. Geführter Ablauf: Schlüssel erzeugen, Private auf A, Public auf B, Host-Key von B in known_hosts von A, Verbindungstest.
## Aufgaben ## Aufgaben
- [ ] Schrittanzeige - [x] Schrittanzeige
- [ ] Zusammenfassung + Dry-Run - [x] Zusammenfassung + Dry-Run
- [ ] `ssh -o BatchMode=yes … true` auf A - [x] `ssh -o BatchMode=yes … true` auf A
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Kern-Anwendungsfall Ende-zu-Ende gegen zwei Test-Container - [x] Kern-Anwendungsfall Ende-zu-Ende gegen zwei Test-Container
## Abhängigkeiten ## Abhängigkeiten
#019 #019
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Fünf Schritte:** Vorlage & Zweck, Quelle, Ziel (Zielbenutzer, optional anlegen mit sudo, Einschränkungen, Alias, HostName/Port aus Sicht von A, Fingerprint von B bestätigen), Zusammenfassung, Ausrollen (Dry-Run, anwenden, Verbindungstest).
- **Vorlage** füllt Zielbenutzer, Anlegen/sudo, Ablaufdatum, `from=`-Muster und Alias vor; Platzhalter werden mit A und B aufgelöst. Ist die Adresse von A keine IP, fragt der Assistent nach.
- **Wiederaufnehmbar:** Erledigte Schritte (Schlüssel, Zielkonto, Zuweisungen) werden beim „Fortsetzen“ übersprungen. Braucht das Zielkonto eine frische Bestätigung, geschieht sie in einem neuen Tab – die Eingaben bleiben stehen.
- **root als Ziel:** deutlicher Hinweis, dass ein Agent wie Claude Code dann uneingeschränkten Zugriff hätte.
- **Verbindungstest:** `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes <alias> true` auf A als Zielbenutzer.
- E2E: Vorlage „Deployment-Zugang“ → `deploy` auf B angelegt → A meldet sich als deploy bei B an.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sshkv", "name": "sshkv",
"version": "0.1.0", "version": "0.3.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;
@@ -0,0 +1,10 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ANLEGEN';
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ENTFERNEN';
+13
View File
@@ -104,6 +104,16 @@ 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
/// Zielbenutzer anlegen und sudoers setzen (#018).
ZIELKONTO_ANLEGEN
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
ZIELKONTO_ENTFERNEN
} }
enum VorgangsStatus { enum VorgangsStatus {
@@ -129,6 +139,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
+89
View File
@@ -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();
} }
+17 -1
View File
@@ -27,9 +27,25 @@ 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/"]' },
{ name: "vorlagen", pfad: "/vorlagen" },
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
{
name: "vorlage-bearbeiten",
pfad: "/vorlagen",
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
},
]; ];
const ANSICHTEN = [ const ANSICHTEN = [
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Bestätigt" };
/** Ziel der Bestaetigung aus dem Assistenten (#020): der Assistent wartet im anderen Tab. */
export default async function BestaetigtSeite() {
await sitzungErforderlich();
return (
<Leerzustand
titel="Anmeldung bestätigt"
text="Die Bestätigung gilt fünf Minuten. Diesen Tab schließen und im Assistenten „Fortsetzen“ wählen."
/>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { Assistent } from "@/components/assistent/Assistent";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { assistentDaten } from "@/server/queries/assistent";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Host-zu-Host-Assistent" };
/**
* Host-zu-Host-Assistent (#020): ein Schluesselpaar fuer genau einen Zweck, Private Key auf
* Host A, Public Key auf Host B, Verbindungstest. Nur Admins – es wird ein Private Key ausgerollt.
*/
export default async function AssistentSeite() {
const { benutzer } = await sitzungErforderlich("privateKeyAusrollen", "/assistent");
const daten = await assistentDaten();
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Übersicht", href: "/" }, { titel: "Host-zu-Host-Assistent" }]} />}
titel="Host-zu-Host-Assistent"
untertitel="Host A soll sich per SSH an Host B anmelden – Schlüssel, Ausrollen und Test in einem Durchgang."
/>
<Assistent daten={daten} bestaetigt={bestaetigungFrisch(benutzer.bestaetigtAm)} />
</div>
);
}
+64 -6
View File
@@ -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 && (
+78
View File
@@ -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>
);
}
+2 -2
View File
@@ -53,7 +53,7 @@ export default async function Uebersicht() {
aktionen={ aktionen={
<> <>
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button> <Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
<Button href="/zuweisungen" icon={<Route className="size-4" />}> <Button href="/assistent" icon={<Route className="size-4" />}>
Host-zu-Host-Assistent Host-zu-Host-Assistent
</Button> </Button>
<Button <Button
@@ -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>
);
}
+153
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+128 -4
View File
@@ -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>
);
}
+373
View File
@@ -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>
);
}
-184
View File
@@ -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>
);
}
+38
View File
@@ -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>
);
}
+211 -15
View File
@@ -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>
); );
} }
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { VorlageArchivierenDialog } from "@/components/vorlagen/VorlageArchivierenDialog";
import { VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { vorlageDetail } from "@/server/queries/vorlagen";
export const metadata: Metadata = { title: "Vorlage bearbeiten" };
export default async function VorlageBearbeiten({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await sitzungErforderlich("einstellungenVerwalten", `/vorlagen/${id}`);
const v = await vorlageDetail(id);
// Archivierte Vorlagen sind fuer die Oberflaeche weg; das Protokoll kennt sie noch (#019).
if (!v || v.archiviertAm) notFound();
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: v.name }]} />}
titel={`${v.name} bearbeiten`}
badges={v.eingebaut ? <Badge>eingebaut</Badge> : undefined}
untertitel={
v.eingebaut
? "Eingebaute Vorlagen lassen sich anpassen, aber nicht archivieren. Ein späterer Seed überschreibt Ihre Änderungen nicht."
: undefined
}
aktionen={!v.eingebaut && <VorlageArchivierenDialog vorlageId={v.id} name={v.name} />}
/>
<VorlagenFormular
vorgabe={{
id: v.id,
name: v.name,
beschreibung: v.beschreibung ?? "",
// Das Formular kennt nur, was Vorlagen erzeugen; ANDERER/DEPLOYMENT kommen hier nicht vor.
schluesselTyp: v.schluesselTyp === "RSA4096" ? "RSA4096" : "ED25519",
kategorie: v.kategorie === "PERSON" ? "PERSON" : "DIENST",
standardLaufzeitTage: v.standardLaufzeitTage,
zielbenutzer: v.zielbenutzer,
zielbenutzerAnlegen: v.zielbenutzerAnlegen,
sudoModus: v.sudoModus,
sudoBefehle: v.sudoBefehle,
authorizedKeysOptionen: v.authorizedKeysOptionen ?? "",
sshConfigAlias: v.sshConfigAlias ?? "",
knownHostsEintragen: v.knownHostsEintragen,
privatNachAusrollenLoeschen: v.privatNachAusrollenLoeschen,
}}
/>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { LEERE_VORLAGE, VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Vorlage anlegen" };
export default async function VorlageNeu() {
// Vorlagen pflegen nur Admins (#019) – wie die uebrigen Einstellungen.
await sitzungErforderlich("einstellungenVerwalten", "/vorlagen/neu");
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: "Neue Vorlage" }]} />}
titel="Vorlage anlegen"
untertitel="Eine Vorlage füllt den Assistenten vor; im Einzelfall lässt sich alles noch ändern."
/>
<VorlagenFormular vorgabe={LEERE_VORLAGE} />
</div>
);
}
+129 -10
View File
@@ -1,18 +1,137 @@
import { FileStack, Pencil, Plus } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; import { Fragment, type ReactNode } from "react";
import { KategorieBadge } from "@/components/schluessel/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { vorlagenListe, type VorlagenListenEintrag } from "@/server/queries/vorlagen";
export const metadata: Metadata = { title: "Vorlagen" }; export const metadata: Metadata = { title: "Vorlagen" };
export default async function Vorlagen() { const SUDO_TEXT = { KEINER: null, BEFEHLE: "sudo: bestimmte Befehle", VOLL: "sudo: voll" } as const;
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich(); function Angabe({ titel, children }: { titel: string; children: ReactNode }) {
return ( return (
<Platzhalterseite <div className="flex min-w-0 flex-col gap-0.5">
titel="Vorlagen" <dt className="text-muted text-[12.5px] font-medium">{titel}</dt>
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup" <dd className="min-w-0 text-[14px]">{children}</dd>
phase={3} </div>
issue="#019" );
/> }
function Mono({ wert }: { wert: string | null }) {
if (!wert) return <span className="text-muted">–</span>;
// Lange Optionslisten sollen auf 390 px nach einem Komma umbrechen, nicht mitten im Wort (#019).
const teile = wert.split(",");
return (
<code className="text-text font-mono text-[13px] [overflow-wrap:anywhere]">
{teile.map((teil, i) => (
<Fragment key={i}>
{teil}
{i < teile.length - 1 && (
<>
,<wbr />
</>
)}
</Fragment>
))}
</code>
);
}
function VorlagenKarte({ v, verwalten }: { v: VorlagenListenEintrag; verwalten: boolean }) {
const sudo = SUDO_TEXT[v.sudoModus];
return (
<Karte
as="article"
titel={v.name}
aktionen={
verwalten && (
<Button
href={`/vorlagen/${v.id}`}
klein
icon={<Pencil className="size-3.5" />}
aria-label={`${v.name} bearbeiten`}
>
Bearbeiten
</Button>
)
}
className="flex flex-col"
>
<div className="-mt-2 mb-4 flex flex-wrap items-center gap-1.5">
<KategorieBadge kategorie={v.kategorie} />
<Badge mono>{v.schluesselTyp === "RSA4096" ? "rsa-4096" : "ed25519"}</Badge>
{v.eingebaut && <Badge>eingebaut</Badge>}
{v.standardLaufzeitTage && (
<Badge variante="messing">
{v.standardLaufzeitTage === 1 ? "1 Tag" : `${v.standardLaufzeitTage} Tage`}
</Badge>
)}
</div>
{v.beschreibung && <p className="text-muted mb-4 text-[14px]">{v.beschreibung}</p>}
<dl className="border-border mt-auto grid gap-3 border-t pt-4">
<Angabe titel="Zielbenutzer">
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Mono wert={v.zielbenutzer} />
{v.zielbenutzerAnlegen && <span className="text-muted text-[13px]">wird angelegt</span>}
{sudo && (
<Badge variante={v.sudoModus === "VOLL" ? "gefahr" : "neutral"}>
{sudo}
{v.sudoModus === "BEFEHLE" && ` (${v.sudoBefehle.length})`}
</Badge>
)}
</span>
</Angabe>
<Angabe titel="authorized_keys-Optionen">
<Mono wert={v.authorizedKeysOptionen} />
</Angabe>
<Angabe titel="Alias-Muster">
<Mono wert={v.sshConfigAlias} />
</Angabe>
</dl>
</Karte>
);
}
/** Vorlagen (#019): lesen darf jeder, anlegen und bearbeiten nur, wer Einstellungen verwaltet. */
export default async function Vorlagen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen");
const vorlagen = await vorlagenListe();
const verwalten = darf(benutzer.rolle, "einstellungenVerwalten");
const anlegen = verwalten && (
<Button href="/vorlagen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
Vorlage anlegen
</Button>
);
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Vorlagen"
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup – sie füllen den Assistenten vor."
aktionen={anlegen}
/>
{vorlagen.length === 0 ? (
<Leerzustand
icon={<FileStack className="size-5" />}
titel="Keine Vorlagen"
text="Die eingebauten Vorlagen legt der Seed an (pnpm db:seed)."
aktion={anlegen}
/>
) : (
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{vorlagen.map((v) => (
<VorlagenKarte key={v.id} v={v} verwalten={verwalten} />
))}
</div>
)}
</div>
); );
} }
@@ -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",
},
});
}
-75
View File
@@ -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 },
];
+637
View File
@@ -0,0 +1,637 @@
"use client";
import { ArrowLeft, ArrowRight, Check, ExternalLink, Play } from "lucide-react";
import { useState } from "react";
import { clsx } from "clsx";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
import {
assistentErzeugenAktion,
assistentZielkontoAktion,
assistentZuweisenAktion,
type SchrittAntwort,
} from "@/server/actions/assistent";
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
import type { AssistentDaten } from "@/server/queries/assistent";
/**
* Host-zu-Host-Assistent (#020). Vier Schritte Eingabe, dann eine Zusammenfassung, dann die
* Ausfuehrung: Schluessel erzeugen → (Zielbenutzer anlegen) → zuweisen → Dry-Run und Ausrollen →
* Verbindungstest. Die Ausfuehrung ist wiederaufnehmbar: was schon erledigt ist, wird nicht
* wiederholt – wichtig, wenn mittendrin eine erneute Bestaetigung noetig wird.
*/
type Werte = {
vorlageId: string;
zweck: string;
ablaufAm: string;
quelleHostId: string;
quelleBenutzer: string;
pfad: string;
zielHostId: string;
zielBenutzer: string;
anlegen: boolean;
sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
sudoBefehle: string;
optionen: string;
quellIp: string;
alias: string;
hostName: string;
port: string;
knownHosts: boolean;
fingerprintGeprueft: boolean;
};
const LEER: Werte = {
vorlageId: "",
zweck: "",
ablaufAm: "",
quelleHostId: "",
quelleBenutzer: "root",
pfad: "",
zielHostId: "",
zielBenutzer: "root",
anlegen: false,
sudoModus: "KEINER",
sudoBefehle: "",
optionen: "",
quellIp: "",
alias: "",
hostName: "",
port: "22",
knownHosts: true,
fingerprintGeprueft: false,
};
const SCHRITTE = ["Vorlage & Zweck", "Quelle (Host A)", "Ziel (Host B)", "Prüfen", "Ausrollen"] as const;
type Ablauf = {
schluesselId?: string;
zielkontoFertig?: boolean;
privatVerwendungId?: string;
laeuft?: string;
fehler?: string;
bestaetigen?: boolean;
};
function datumIn(tage: number): string {
return new Date(Date.now() + tage * 86_400_000).toISOString().slice(0, 10);
}
function kurz(text: string): string {
return (
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 40) || "zugang"
);
}
/** Wartet auf das Ende eines Vorgangs (fuer die Kette im Klick, nicht als Hook). */
async function warten(
vorgangId: string,
): Promise<{ status: string; fehler: string | null; ergebnis: unknown }> {
for (;;) {
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
const stand = (await antwort.json()) as { status: string; fehler: string | null; ergebnis: unknown };
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
await new Promise((weiter) => setTimeout(weiter, 800));
}
}
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
const [schritt, setSchritt] = useState(0);
const [w, setW] = useState<Werte>(LEER);
const [ablauf, setAblauf] = useState<Ablauf>({});
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => setW((alt) => ({ ...alt, [feld]: wert }));
const vorlage = daten.vorlagen.find((v) => v.id === w.vorlageId);
const quelle = daten.hosts.find((h) => h.id === w.quelleHostId);
const ziel = daten.hosts.find((h) => h.id === w.zielHostId);
function vorlageWaehlen(id: string) {
const v = daten.vorlagen.find((x) => x.id === id);
setW((alt) => ({
...alt,
vorlageId: id,
...(v
? {
zielBenutzer: v.zielbenutzer,
anlegen: v.zielbenutzerAnlegen,
sudoModus: v.sudoModus,
sudoBefehle: v.sudoBefehle.join("\n"),
knownHosts: v.knownHostsEintragen,
ablaufAm: v.standardLaufzeitTage ? datumIn(v.standardLaufzeitTage) : "",
}
: {}),
}));
}
/** Beim Wechsel zu „Ziel“: Muster der Vorlage mit A und B aufloesen (nur leere Felder). */
function zielVorbelegen(zielId: string) {
const b = daten.hosts.find((h) => h.id === zielId);
const quellIp = w.quellIp || (quelle && istIpAdresse(quelle.adresse) ? quelle.adresse : "");
const werte = {
quellHost: quelle?.name,
quellHostIp: quellIp,
zielHost: b?.name,
zielHostIp: b?.adresse,
};
const aufloesen = (muster: string | null | undefined) => {
if (!muster) return "";
try {
return platzhalterErsetzen(muster, werte);
} catch {
return muster;
}
};
setW((alt) => ({
...alt,
zielHostId: zielId,
quellIp,
optionen: alt.optionen || aufloesen(vorlage?.authorizedKeysOptionen),
alias: alt.alias || aufloesen(vorlage?.sshConfigAlias) || (b ? b.name : ""),
hostName: b ? b.adresse : "",
port: b ? String(b.port) : "22",
pfad: alt.pfad || `~/.ssh/id_ed25519_${kurz(alt.zweck)}`,
fingerprintGeprueft: false,
}));
}
const offenePlatzhalter = /\{\{/.test(w.optionen) || /\{\{/.test(w.alias);
let sudoVorschau: string | null = null;
let sudoFehler: string | undefined;
if (w.anlegen) {
try {
sudoVorschau = sudoersInhalt(
w.zielBenutzer,
w.sudoModus,
w.sudoBefehle.split("\n").filter((z) => z.trim()),
);
} catch (fehler) {
sudoFehler = (fehler as Error).message;
}
}
const schrittOk = [
w.zweck.trim().length > 0,
Boolean(w.quelleHostId && w.quelleBenutzer),
Boolean(w.zielHostId && w.zielHostId !== w.quelleHostId && w.zielBenutzer && w.alias && w.hostName) &&
!offenePlatzhalter &&
!sudoFehler &&
(!w.knownHosts || w.fingerprintGeprueft),
true,
];
async function ausfuehren() {
let stand = { ...ablauf, fehler: undefined, bestaetigen: false } as Ablauf;
const melden = (neu: Partial<Ablauf>) => {
stand = { ...stand, ...neu };
setAblauf(stand);
};
const pruefen = (antwort: SchrittAntwort) => {
if (antwort.fehler || antwort.bestaetigen) {
melden({ laeuft: undefined, fehler: antwort.fehler, bestaetigen: Boolean(antwort.bestaetigen) });
return false;
}
return true;
};
try {
if (!stand.schluesselId) {
melden({ laeuft: "Schlüsselpaar wird erzeugt …" });
const antwort = await assistentErzeugenAktion({
name: w.zweck.trim(),
vorlageId: w.vorlageId || null,
ablaufAm: w.ablaufAm || null,
});
if (!pruefen(antwort)) return;
const ergebnis = await warten(antwort.vorgangId!);
if (ergebnis.status !== "ERLEDIGT")
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Erzeugen fehlgeschlagen." });
melden({ schluesselId: (ergebnis.ergebnis as { schluesselId: string }).schluesselId });
}
if (w.anlegen && !stand.zielkontoFertig) {
melden({ laeuft: `Benutzer ${w.zielBenutzer} wird auf ${ziel?.name} eingerichtet …` });
const antwort = await assistentZielkontoAktion({
hostId: w.zielHostId,
benutzer: w.zielBenutzer,
modus: w.sudoModus,
befehle: w.sudoBefehle.split("\n").filter((z) => z.trim()),
});
if (!pruefen(antwort)) return;
const ergebnis = await warten(antwort.vorgangId!);
if (ergebnis.status !== "ERLEDIGT")
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Zielkonto fehlgeschlagen." });
melden({ zielkontoFertig: true });
}
if (!stand.privatVerwendungId) {
melden({ laeuft: "Zuweisungen werden angelegt …" });
const antwort = await assistentZuweisenAktion({
schluesselId: stand.schluesselId!,
quelle: { hostId: w.quelleHostId, benutzer: w.quelleBenutzer, pfad: w.pfad },
ziel: { hostId: w.zielHostId, benutzer: w.zielBenutzer, optionen: w.optionen.trim() || null },
ssh: { alias: w.alias, hostName: w.hostName, port: Number(w.port), knownHosts: w.knownHosts },
});
if (!pruefen(antwort)) return;
melden({ privatVerwendungId: antwort.privatVerwendungId });
}
melden({ laeuft: undefined });
setSchritt(4);
} catch (fehler) {
melden({ laeuft: undefined, fehler: (fehler as Error).message });
}
}
return (
<div className="flex flex-col gap-6">
<ol className="flex flex-wrap gap-2" aria-label="Schritte">
{SCHRITTE.map((titel, index) => (
<li
key={titel}
aria-current={index === schritt ? "step" : undefined}
className={clsx(
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
index === schritt
? "border-accent text-accent bg-raised font-medium"
: index < schritt
? "border-border text-text"
: "border-border text-muted",
)}
>
<span className="font-mono">
{index < schritt ? <Check className="size-4" aria-hidden /> : index + 1}
</span>
{titel}
</li>
))}
</ol>
{schritt === 0 && (
<Karte
titel="Vorlage & Zweck"
untertitel="Eine Vorlage füllt Zielbenutzer, Einschränkungen und Ablauf vor."
>
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Vorlage"
name="vorlageId"
value={w.vorlageId}
onChange={(e) => vorlageWaehlen(e.target.value)}
>
<option value="">– ohne Vorlage –</option>
{daten.vorlagen.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</Auswahlliste>
{vorlage?.beschreibung && <p className="text-muted text-[14px]">{vorlage.beschreibung}</p>}
<Feld
beschriftung="Zweck"
name="zweck"
value={w.zweck}
onChange={(e) => setze("zweck", e.target.value)}
placeholder="Backup srv-backup01 → srv-pve01"
required
/>
<Feld
beschriftung="Ablaufdatum (optional)"
name="ablaufAm"
type="date"
value={w.ablaufAm}
onChange={(e) => setze("ablaufAm", e.target.value)}
hinweis="Projektbezogene Zugänge sollten ablaufen."
/>
</div>
</Karte>
)}
{schritt === 1 && (
<Karte
titel="Quelle – Host A"
untertitel="Dieser Host bekommt den Private Key und meldet sich später an."
>
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Host A"
name="quelleHostId"
value={w.quelleHostId}
onChange={(e) => setze("quelleHostId", e.target.value)}
>
<option value="">Host wählen …</option>
{daten.hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Auswahlliste>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Benutzer auf A"
name="quelleBenutzer"
value={w.quelleBenutzer}
onChange={(e) => setze("quelleBenutzer", e.target.value)}
mono
/>
<Feld
beschriftung="Pfad des Private Keys"
name="pfad"
value={w.pfad || `~/.ssh/id_ed25519_${kurz(w.zweck)}`}
onChange={(e) => setze("pfad", e.target.value)}
mono
/>
</div>
</div>
</Karte>
)}
{schritt === 2 && (
<Karte titel="Ziel – Host B" untertitel="Dieser Host nimmt den Public Key an.">
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Host B"
name="zielHostId"
value={w.zielHostId}
onChange={(e) => zielVorbelegen(e.target.value)}
>
<option value="">Host wählen …</option>
{daten.hosts
.filter((h) => h.id !== w.quelleHostId)
.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Auswahlliste>
<Feld
beschriftung="Benutzer auf B"
name="zielBenutzer"
value={w.zielBenutzer}
onChange={(e) => setze("zielBenutzer", e.target.value)}
mono
/>
{w.zielBenutzer === "root" && (
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[13.5px]">
▲ Zugang als root: Wer den Private Key auf A nutzt – etwa ein KI-Agent wie Claude Code –, hat
auf B uneingeschränkten Zugriff. Für Deployments ist ein eigener Benutzer wie „deploy“ besser.
</p>
)}
<Ankreuzfeld
beschriftung={`Benutzer „${w.zielBenutzer}“ auf B anlegen bzw. pflegen`}
erklaerung="Mit Home und ~/.ssh; optional mit sudoers-Datei. Verlangt eine frische Bestätigung."
checked={w.anlegen}
onChange={(e) => setze("anlegen", e.target.checked)}
/>
{w.anlegen && (
<div className="border-border flex flex-col gap-3 border-l-2 pl-4">
<Auswahlliste
beschriftung="sudo"
name="sudoModus"
value={w.sudoModus}
onChange={(e) => setze("sudoModus", e.target.value as Werte["sudoModus"])}
>
<option value="KEINER">keine sudo-Rechte</option>
<option value="BEFEHLE">bestimmte Befehle</option>
<option value="VOLL">voller sudo-Zugriff</option>
</Auswahlliste>
{w.sudoModus === "BEFEHLE" && (
<Textbereich
beschriftung="Erlaubte Befehle (einer je Zeile, absoluter Pfad)"
name="sudoBefehle"
value={w.sudoBefehle}
onChange={(e) => setze("sudoBefehle", e.target.value)}
rows={3}
/>
)}
{sudoFehler && <p className="text-danger text-[13px]">{sudoFehler}</p>}
{sudoVorschau && (
<pre className="border-border bg-bg rounded-box overflow-x-auto border p-3 font-mono text-[12.5px]">
{sudoVorschau}
</pre>
)}
</div>
)}
{quelle && !istIpAdresse(quelle.adresse) && (
<Feld
beschriftung={`IP von ${quelle.name} (für from="…")`}
name="quellIp"
value={w.quellIp}
onChange={(e) => setze("quellIp", e.target.value)}
mono
hinweis="Die Adresse, unter der B den Host A sieht."
/>
)}
<Feld
beschriftung="Einschränkungen in authorized_keys"
name="optionen"
value={w.optionen}
onChange={(e) => setze("optionen", e.target.value)}
mono
placeholder={
quelle && istIpAdresse(quelle.adresse) ? `from="${quelle.adresse}"` : 'from="10.0.0.5"'
}
hinweis={
offenePlatzhalter
? "Es sind noch Platzhalter offen."
: 'Empfohlen: from="<IP von A>", damit der Schlüssel nur von dort funktioniert.'
}
/>
<div className="grid gap-4 sm:grid-cols-3">
<Feld
beschriftung="Alias auf A"
name="alias"
value={w.alias}
onChange={(e) => setze("alias", e.target.value)}
mono
/>
<Feld
beschriftung="HostName (aus Sicht von A)"
name="hostName"
value={w.hostName}
onChange={(e) => setze("hostName", e.target.value)}
mono
/>
<Feld
beschriftung="Port"
name="port"
type="number"
value={w.port}
onChange={(e) => setze("port", e.target.value)}
mono
/>
</div>
<Ankreuzfeld
beschriftung="Host-Key von B in known_hosts von A eintragen"
checked={w.knownHosts}
onChange={(e) => setze("knownHosts", e.target.checked)}
/>
{w.knownHosts && ziel && (
<div className="border-border rounded-box flex flex-col gap-2 border p-3">
{ziel.hostKeys.map((k) => (
<p key={k.fingerprint} className="font-mono text-[12.5px] break-all">
<span className="text-muted">{k.algorithmus}</span> {k.fingerprint}
</p>
))}
<Ankreuzfeld
beschriftung="Fingerprint von B ist bestätigt"
erklaerung="Derselbe Host-Key, dem sshkv beim Bootstrap von B vertraut hat."
checked={w.fingerprintGeprueft}
onChange={(e) => setze("fingerprintGeprueft", e.target.checked)}
/>
</div>
)}
</div>
</Karte>
)}
{schritt === 3 && (
<Karte
titel="Zusammenfassung"
untertitel="Noch ist nichts passiert. Ausgerollt wird erst nach dem Dry-Run."
>
<dl className="grid gap-x-6 gap-y-3 text-[14px] sm:grid-cols-[12rem_minmax(0,1fr)]">
<dt className="text-muted">Schlüssel</dt>
<dd>
ed25519 · {w.zweck}
{w.ablaufAm && (
<span className="text-accent">
{" "}
· läuft ab am {w.ablaufAm.split("-").reverse().join(".")}
</span>
)}
</dd>
<dt className="text-muted">Private Key</dt>
<dd className="font-mono text-[13px] break-all">
{w.quelleBenutzer}@{quelle?.name}:{w.pfad}
</dd>
<dt className="text-muted">~/.ssh/config auf A</dt>
<dd className="font-mono text-[13px] break-all">
Host {w.alias} → {w.zielBenutzer}@{w.hostName}:{w.port}
</dd>
<dt className="text-muted">Public Key</dt>
<dd className="font-mono text-[13px] break-all">
{w.optionen && `${w.optionen} · `}
{w.zielBenutzer}@{ziel?.name}
</dd>
{w.anlegen && (
<>
<dt className="text-muted">Zielbenutzer</dt>
<dd>
„{w.zielBenutzer}“ auf {ziel?.name} anlegen,{" "}
{w.sudoModus === "KEINER" ? (
"ohne sudo"
) : w.sudoModus === "VOLL" ? (
<span className="text-danger">voller sudo-Zugriff</span>
) : (
"sudo für bestimmte Befehle"
)}
</dd>
</>
)}
</dl>
{w.anlegen && !bestaetigt && !ablauf.bestaetigen && (
<p className="text-accent mt-4 text-[13.5px]">
Das Anlegen des Benutzers verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
Ausführen danach.
</p>
)}
<div className="border-border mt-5 flex flex-col gap-3 border-t pt-4">
<Formularfehler text={ablauf.fehler} />
{ablauf.bestaetigen && (
<a
href="/bestaetigen?ziel=%2Fassistent%2Fbestaetigt"
target="_blank"
rel="noopener"
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
>
<ExternalLink className="size-4" aria-hidden /> Anmeldung in neuem Tab bestätigen, dann hier
erneut ausführen
</a>
)}
{ablauf.laeuft && (
<p role="status" className="text-muted text-[14px]">
{ablauf.laeuft}
</p>
)}
<Button
variante="primaer"
icon={<Play className="size-4" />}
disabled={Boolean(ablauf.laeuft)}
onClick={() => void ausfuehren()}
>
{ablauf.schluesselId ? "Fortsetzen" : "Vorbereiten"}
</Button>
</div>
</Karte>
)}
{schritt === 4 && ablauf.schluesselId && (
<>
<Karte
titel="Dry-Run und Ausrollen"
untertitel="Prüfen Sie die Änderungen auf A und B, dann anwenden."
>
<AusrollenPanel auswahl={{ schluesselId: ablauf.schluesselId }} offen={2} />
</Karte>
<Karte
titel="Verbindung testen"
untertitel={`Auf ${quelle?.name} als ${w.quelleBenutzer}: ssh ${w.alias} true`}
>
{ablauf.privatVerwendungId && (
<VorgangsKnopf
aktion={verbindungTestenAktion.bind(null, ablauf.privatVerwendungId)}
beschriftung="Verbindung von A nach B testen"
variante="primaer"
/>
)}
<p className="text-muted mt-3 text-[13.5px]">
Zum Schlüssel:{" "}
<a className="text-accent" href={`/schluessel/${ablauf.schluesselId}`}>
{w.zweck}
</a>
</p>
</Karte>
</>
)}
{schritt < 3 && (
<div className="flex flex-wrap justify-between gap-3">
<Button
icon={<ArrowLeft className="size-4" />}
disabled={schritt === 0}
onClick={() => setSchritt(schritt - 1)}
>
Zurück
</Button>
<Button
variante="primaer"
icon={<ArrowRight className="size-4" />}
disabled={!schrittOk[schritt]}
onClick={() => {
if (schritt === 1 && !w.pfad) setze("pfad", `~/.ssh/id_ed25519_${kurz(w.zweck)}`);
setSchritt(schritt + 1);
}}
>
Weiter
</Button>
</div>
)}
{schritt === 3 && !ablauf.schluesselId && (
<div>
<Button
icon={<ArrowLeft className="size-4" />}
disabled={Boolean(ablauf.laeuft)}
onClick={() => setSchritt(2)}
>
Zurück
</Button>
</div>
)}
</div>
);
}
@@ -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>
</>
);
}
+192
View File
@@ -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>
);
}
+3
View File
@@ -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 },
]; ];
+115
View File
@@ -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>
</>
);
}
+151
View File
@@ -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>
</>
);
}
+66
View File
@@ -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>
);
}
+400
View File
@@ -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>
);
}
+8 -1
View File
@@ -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}
+68
View File
@@ -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,48 @@
"use client";
import { Archive } from "lucide-react";
import { useActionState, useRef } from "react";
import { Button, Formularfehler } from "@/components/ui";
import type { FormularZustand } from "@/server/actions/formular";
import { vorlageArchivierenAktion } from "@/server/actions/vorlagen";
/** Archivieren mit Rueckfrage (#019), gleiches Muster wie bei Hosts (#008). */
export function VorlageArchivierenDialog({ vorlageId, name }: { vorlageId: string; name: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(vorlageArchivierenAktion, {});
return (
<>
<Button
variante="gefahr"
icon={<Archive className="size-4" />}
onClick={() => dialog.current?.showModal()}
>
Vorlage archivieren
</Button>
<dialog
ref={dialog}
aria-labelledby="archivieren-titel"
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
>
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="id" value={vorlageId} />
<h2 id="archivieren-titel" className="text-[17px] font-semibold">
„{name}“ archivieren?
</h2>
<p className="text-muted text-[14.5px]">
Die Vorlage steht im Assistenten nicht mehr zur Auswahl. Schlüssel, die mit ihr angelegt wurden,
bleiben unverändert; das Protokoll bleibt erhalten.
</p>
<Formularfehler text={zustand.fehler} />
<div className="flex flex-wrap justify-end gap-2.5">
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
<Button type="submit" variante="gefahr" disabled={laeuft}>
{laeuft ? "Archiviere …" : "Archivieren"}
</Button>
</div>
</form>
</dialog>
</>
);
}
@@ -0,0 +1,377 @@
"use client";
import { TriangleAlert } from "lucide-react";
import { useState } from "react";
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Karte, Textbereich } from "@/components/ui";
import { MonoBlock } from "@/components/ui/MonoBlock";
import { optionenPruefen } from "@/lib/ssh/optionen";
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
import { PLATZHALTER, platzhalterErsetzen, type PlatzhalterWerte } from "@/lib/vorlagen";
import { vorlageSpeichernAktion } from "@/server/actions/vorlagen";
type SudoModus = "KEINER" | "BEFEHLE" | "VOLL";
export type VorlageFormularVorgabe = {
id?: string;
name: string;
beschreibung: string;
schluesselTyp: "ED25519" | "RSA4096";
kategorie: "DIENST" | "PERSON";
standardLaufzeitTage: number | null;
zielbenutzer: string;
zielbenutzerAnlegen: boolean;
sudoModus: SudoModus;
sudoBefehle: string[];
authorizedKeysOptionen: string;
sshConfigAlias: string;
knownHostsEintragen: boolean;
privatNachAusrollenLoeschen: boolean;
};
export const LEERE_VORLAGE: VorlageFormularVorgabe = {
name: "",
beschreibung: "",
schluesselTyp: "ED25519",
kategorie: "DIENST",
standardLaufzeitTage: null,
zielbenutzer: "",
zielbenutzerAnlegen: false,
sudoModus: "KEINER",
sudoBefehle: [],
authorizedKeysOptionen: "",
sshConfigAlias: "",
knownHostsEintragen: true,
privatNachAusrollenLoeschen: false,
};
/** Beispielwerte der Vorschau – sichtbar andere als die Probewerte des Servers. */
const BEISPIEL: Required<PlatzhalterWerte> = {
quellHost: "srv-build01",
quellHostIp: "10.0.0.5",
zielHost: "srv-web01",
zielHostIp: "10.0.0.20",
};
type Vorschau = { wert: string | null; fehler?: string };
/**
* Die Vorschau ist nur Anzeige (#019): Fehler werden zum Hinweis, nicht zum Absturz – die
* verbindliche Pruefung macht der Server beim Speichern mit denselben Funktionen.
*/
function vorschau(berechnen: () => string | null): Vorschau {
try {
return { wert: berechnen() };
} catch (fehler) {
return { wert: null, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
}
}
function befehlsZeilen(text: string): string[] {
return text
.split("\n")
.map((b) => b.trim())
.filter(Boolean);
}
function Vorschauzeile({ titel, ergebnis, leer }: { titel: string; ergebnis: Vorschau; leer: string }) {
return (
<div className="flex min-w-0 flex-col gap-1">
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
<dd className="min-w-0">
{ergebnis.fehler ? (
<span className="text-danger text-[13.5px]">{ergebnis.fehler}</span>
) : ergebnis.wert ? (
<code className="text-text font-mono text-[13px] break-all">{ergebnis.wert}</code>
) : (
<span className="text-muted text-[13.5px]">{leer}</span>
)}
</dd>
</div>
);
}
/** Formular fuer neue und bestehende Vorlagen (#019). Regeln prueft der Server. */
export function VorlagenFormular({ vorgabe }: { vorgabe: VorlageFormularVorgabe }) {
const bearbeiten = Boolean(vorgabe.id);
const { zustand, absenden, laeuft } = useFormularAktion(vorlageSpeichernAktion);
const f = zustand.feldfehler ?? {};
// Kontrolliert nur, was die Vorschau braucht; der Rest bleibt unkontrolliert.
const [zielbenutzer, setZielbenutzer] = useState(vorgabe.zielbenutzer);
const [sudoModus, setSudoModus] = useState<SudoModus>(vorgabe.sudoModus);
const [befehle, setBefehle] = useState(vorgabe.sudoBefehle.join("\n"));
const [optionen, setOptionen] = useState(vorgabe.authorizedKeysOptionen);
const [alias, setAlias] = useState(vorgabe.sshConfigAlias);
const optionenVorschau = vorschau(() =>
optionen.trim() ? optionenPruefen(platzhalterErsetzen(optionen.trim(), BEISPIEL)) || null : null,
);
const aliasVorschau = vorschau(() => (alias.trim() ? platzhalterErsetzen(alias.trim(), BEISPIEL) : null));
const sudoersVorschau = vorschau(() =>
sudoModus === "KEINER" ? null : sudoersInhalt(zielbenutzer.trim(), sudoModus, befehlsZeilen(befehle)),
);
return (
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
<Formularfehler text={zustand.fehler} />
<Karte titel="Allgemein">
<div className="flex flex-col gap-4">
<Feld
beschriftung="Name"
name="name"
defaultValue={vorgabe.name}
required
maxLength={80}
autoComplete="off"
placeholder="z. B. Monitoring-Zugang"
fehler={f["name"]}
/>
<Textbereich
beschriftung="Beschreibung (optional)"
name="beschreibung"
defaultValue={vorgabe.beschreibung}
maxLength={500}
fehler={f["beschreibung"]}
/>
</div>
</Karte>
<Karte titel="Schlüssel">
<div className="flex flex-col gap-5">
<Feldgruppe legende="Typ" fehler={f["schluesselTyp"]}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Ankreuzfeld
type="radio"
name="schluesselTyp"
value="ED25519"
defaultChecked={vorgabe.schluesselTyp === "ED25519"}
beschriftung={<span className="font-mono">ed25519</span>}
erklaerung="Empfohlen – kurz, schnell, sicher."
/>
<Ankreuzfeld
type="radio"
name="schluesselTyp"
value="RSA4096"
defaultChecked={vorgabe.schluesselTyp === "RSA4096"}
beschriftung={<span className="font-mono">rsa-4096</span>}
erklaerung="Nur für alte Gegenstellen ohne ed25519."
/>
</div>
</Feldgruppe>
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Ankreuzfeld
type="radio"
name="kategorie"
value="DIENST"
defaultChecked={vorgabe.kategorie === "DIENST"}
beschriftung="Dienst"
erklaerung="Host-zu-Host oder Automatisierung; sshkv verwahrt den Private Key."
/>
<Ankreuzfeld
type="radio"
name="kategorie"
value="PERSON"
defaultChecked={vorgabe.kategorie === "PERSON"}
beschriftung="Person"
erklaerung="Der Private Key bleibt bei der Person; kein Benutzer anlegen, kein sudo."
/>
</div>
</Feldgruppe>
<Feld
beschriftung="Standard-Laufzeit in Tagen (optional)"
name="standardLaufzeitTage"
type="number"
inputMode="numeric"
min={1}
max={3650}
defaultValue={vorgabe.standardLaufzeitTage ?? ""}
hinweis="Leer lassen für Schlüssel ohne Ablaufdatum."
fehler={f["standardLaufzeitTage"]}
className="sm:max-w-xs"
/>
</div>
</Karte>
<Karte titel="Zielkonto" untertitel="Unter welchem Benutzer der Schlüssel auf dem Zielhost landet">
<div className="flex flex-col gap-5">
<Feld
beschriftung="Zielbenutzer"
name="zielbenutzer"
value={zielbenutzer}
onChange={(e) => setZielbenutzer(e.target.value)}
mono
required
maxLength={32}
autoComplete="off"
spellCheck={false}
placeholder="deploy"
hinweis="Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen."
fehler={f["zielbenutzer"]}
className="sm:max-w-xs"
/>
<Ankreuzfeld
name="zielbenutzerAnlegen"
value="ja"
defaultChecked={vorgabe.zielbenutzerAnlegen}
beschriftung="Benutzer anlegen, falls er fehlt"
erklaerung="sshkv legt ihn beim Ausrollen an und entfernt ihn beim Widerruf wieder."
/>
<Feldgruppe
legende="sudo-Rechte"
hinweis="Nur für Benutzer, die sshkv selbst anlegt."
fehler={f["sudoModus"]}
>
<div className="grid gap-x-4 sm:grid-cols-3">
{(
[
["KEINER", "keine"],
["BEFEHLE", "bestimmte Befehle"],
["VOLL", "voll"],
] as const
).map(([wert, text]) => (
<Ankreuzfeld
key={wert}
type="radio"
name="sudoModus"
value={wert}
checked={sudoModus === wert}
onChange={() => setSudoModus(wert)}
beschriftung={text}
/>
))}
</div>
</Feldgruppe>
{/* Ausgeblendet statt entfernt, damit die Befehle beim Hin- und Herschalten bleiben. */}
<Textbereich
beschriftung="Erlaubte Befehle"
name="sudoBefehle"
value={befehle}
onChange={(e) => setBefehle(e.target.value)}
rows={4}
spellCheck={false}
placeholder={"/usr/bin/systemctl restart nginx\n/usr/bin/systemctl reload nginx"}
hinweis="Ein Befehl je Zeile, mit absolutem Pfad. Argumente sind erlaubt, Platzhalter nicht."
fehler={f["sudoBefehle"]}
className={sudoModus === "BEFEHLE" ? "[&_textarea]:font-mono [&_textarea]:text-[13px]" : "hidden"}
/>
{sudoModus === "VOLL" && (
<p
role="note"
className="border-danger-border bg-danger-soft text-danger rounded-box flex items-start gap-2.5 border px-3 py-2.5 text-[14px]"
>
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
Voller sudo-Zugang ohne Passwort: Wer diesen Schlüssel hat, ist auf dem Zielhost faktisch{" "}
<strong className="font-semibold">root</strong>. Nur wählen, wenn bestimmte Befehle nicht
reichen.
</span>
</p>
)}
</div>
</Karte>
<Karte titel="authorized_keys & ssh">
<div className="flex flex-col gap-5">
<Feld
beschriftung="authorized_keys-Optionen (optional)"
name="authorizedKeysOptionen"
value={optionen}
onChange={(e) => setOptionen(e.target.value)}
mono
autoComplete="off"
spellCheck={false}
placeholder={'from="{{quellHostIp}}",no-port-forwarding'}
fehler={f["authorizedKeysOptionen"]}
/>
<Feld
beschriftung="Alias-Muster für ~/.ssh/config (optional)"
name="sshConfigAlias"
value={alias}
onChange={(e) => setAlias(e.target.value)}
mono
autoComplete="off"
spellCheck={false}
placeholder="deploy-{{zielHost}}"
fehler={f["sshConfigAlias"]}
/>
<div className="border-border bg-sidebar rounded-box text-muted border px-4 py-3 text-[13px]">
<p>
Platzhalter:{" "}
{PLATZHALTER.map((p, i) => (
<span key={p}>
{i > 0 && ", "}
<code className="text-text font-mono whitespace-nowrap">{`{{${p}}}`}</code>
</span>
))}
. Beim Ausrollen setzt sshkv Name und IP von Quell- und Zielhost ein.
</p>
<p className="mt-1.5">
Beispiel: <code className="text-text font-mono break-all">{'from="{{quellHostIp}}"'}</code> wird
zu <code className="text-text font-mono break-all">{`from="${BEISPIEL.quellHostIp}"`}</code>.
</p>
</div>
<div className="flex flex-col">
<Ankreuzfeld
name="knownHostsEintragen"
value="ja"
defaultChecked={vorgabe.knownHostsEintragen}
beschriftung="Host-Key des Ziels in known_hosts der Quelle eintragen"
/>
<Ankreuzfeld
name="privatNachAusrollenLoeschen"
value="ja"
defaultChecked={vorgabe.privatNachAusrollenLoeschen}
beschriftung="Private Key nach dem Ausrollen in sshkv löschen"
erklaerung="Dann liegt er nur noch auf dem Quellhost und lässt sich nicht erneut ausrollen."
/>
</div>
</div>
</Karte>
<Karte
titel="Vorschau"
untertitel={
<>
Mit Beispielwerten: Quelle <span className="font-mono">{BEISPIEL.quellHost}</span> (
<span className="font-mono">{BEISPIEL.quellHostIp}</span>), Ziel{" "}
<span className="font-mono">{BEISPIEL.zielHost}</span> (
<span className="font-mono">{BEISPIEL.zielHostIp}</span>)
</>
}
>
<div aria-live="polite" className="flex flex-col gap-4">
<dl className="grid gap-4 sm:grid-cols-2">
<Vorschauzeile titel="authorized_keys-Optionen" ergebnis={optionenVorschau} leer="keine" />
<Vorschauzeile titel="Alias" ergebnis={aliasVorschau} leer="kein Eintrag in ~/.ssh/config" />
</dl>
{sudoModus !== "KEINER" &&
(sudoersVorschau.fehler ? (
<p className="text-danger text-[13.5px]">sudoers: {sudoersVorschau.fehler}</p>
) : (
sudoersVorschau.wert && (
<div className="flex min-w-0 flex-col gap-1.5">
<p className="text-muted text-[13px] font-medium">
<span className="font-mono">/etc/sudoers.d/sshkv-{zielbenutzer.trim()}</span>
</p>
{/* Ohne Umbruch: sudoers ist zeilenbasiert, ein optischer Umbruch taeuschte eine
zweite Regel vor (#019). Der Block scrollt dann fuer sich. */}
<MonoBlock wert={sudoersVorschau.wert} beschriftung="sudoers-Inhalt" />
</div>
)
))}
</div>
</Karte>
<div className="flex flex-wrap gap-2.5">
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Vorlage anlegen"}
</Button>
<Button href="/vorlagen">Abbrechen</Button>
</div>
</form>
);
}
+52
View File
@@ -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>;
+292
View File
@@ -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}`;
}
+266
View File
@@ -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");
}
+125
View File
@@ -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(",");
}
+83
View File
@@ -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}`;
}
+77
View File
@@ -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)),
};
}
+74
View File
@@ -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;
}
+134
View File
@@ -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}
`;
}
+160
View File
@@ -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,
};
}
+123
View File
@@ -0,0 +1,123 @@
"use server";
import { ZielkontoFehler } from "@/lib/ssh/zielkonto";
import { z } from "@/lib/validierung";
import { akteurVon } from "@/server/actions/formular";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { HostFehler } from "@/server/services/hosts";
import { erzeugenAnfordern } from "@/server/services/schluessel";
import {
VerwendungsFehler,
privateVerwendungAnlegen,
publicVerwendungAnlegen,
} from "@/server/services/verwendungen";
import { zielkontoAnfordern } from "@/server/services/zielkonten";
/**
* Schritte des Host-zu-Host-Assistenten (#020). Jeder Schritt ist eine eigene Aktion, die
* Oberflaeche verkettet sie und wartet dazwischen auf den Worker. So bleibt jede Aktion
* duenn, und jeder Schritt hat seine eigene Rechtepruefung.
*/
export type SchrittAntwort = {
readonly vorgangId?: string;
readonly privatVerwendungId?: string;
readonly fehler?: string;
/** Kritischer Schritt ohne frische Bestaetigung: dorthin, dann weiter. */
readonly bestaetigen?: boolean;
};
function alsAntwort(fehler: unknown): SchrittAntwort {
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
if (
fehler instanceof RechteFehler ||
fehler instanceof VerwendungsFehler ||
fehler instanceof ZielkontoFehler ||
fehler instanceof HostFehler
) {
return { fehler: fehler.message };
}
throw fehler;
}
const id = z.string().min(1).max(64);
export async function assistentErzeugenAktion(eingabe: {
name: string;
vorlageId: string | null;
ablaufAm: string | null;
}): Promise<SchrittAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
return {
vorgangId: await erzeugenAnfordern(
{
name: eingabe.name,
vorlageId: eingabe.vorlageId,
ablaufAm: eingabe.ablaufAm,
tags: ["host-zu-host"],
},
undefined,
akteurVon(benutzer),
),
};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function assistentZielkontoAktion(eingabe: {
hostId: string;
benutzer: string;
modus: "KEINER" | "BEFEHLE" | "VOLL";
befehle: string[];
}): Promise<SchrittAntwort> {
try {
// Ein neuer Benutzer mit sudo ist so viel wert wie root: Admin mit frischer Bestaetigung.
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
return {
vorgangId: await zielkontoAnfordern({ ...eingabe, hostId: id.parse(eingabe.hostId) }, benutzer),
};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function assistentZuweisenAktion(eingabe: {
schluesselId: string;
quelle: { hostId: string; benutzer: string; pfad: string };
ziel: { hostId: string; benutzer: string; optionen: string | null };
ssh: { alias: string; hostName: string; port: number; knownHosts: boolean };
}): Promise<SchrittAntwort> {
try {
const { benutzer } = await rechtErforderlich("privateKeyAusrollen");
const akteur = akteurVon(benutzer);
const privatVerwendungId = await privateVerwendungAnlegen(
{
schluesselId: id.parse(eingabe.schluesselId),
hostId: id.parse(eingabe.quelle.hostId),
zielbenutzer: eingabe.quelle.benutzer,
pfad: eingabe.quelle.pfad,
sshConfig: {
alias: eingabe.ssh.alias,
hostName: eingabe.ssh.hostName,
benutzer: eingabe.ziel.benutzer,
port: eingabe.ssh.port,
},
knownHostsFuer: eingabe.ssh.knownHosts ? [id.parse(eingabe.ziel.hostId)] : [],
},
akteur,
);
await publicVerwendungAnlegen(
{
schluesselId: eingabe.schluesselId,
hostId: id.parse(eingabe.ziel.hostId),
zielbenutzer: eingabe.ziel.benutzer,
optionen: eingabe.ziel.optionen,
},
akteur,
);
return { privatVerwendungId };
} catch (fehler) {
return alsAntwort(fehler);
}
}
+43
View File
@@ -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);
}
}
+68
View File
@@ -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");
}
+70
View File
@@ -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);
}
}
+137
View File
@@ -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}`);
}
+173
View File
@@ -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);
}
}
+73
View File
@@ -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");
}
+4
View File
@@ -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",
+44
View File
@@ -0,0 +1,44 @@
import { prisma } from "@/lib/db";
/** Was der Host-zu-Host-Assistent zur Auswahl braucht (#020) – nur oeffentliche Angaben. */
export async function assistentDaten() {
const [hosts, vorlagen] = await Promise.all([
prisma.host.findMany({
where: { archiviertAm: null },
select: {
id: true,
name: true,
adresse: true,
port: true,
bootstrapAm: true,
hostSchluessel: { where: { status: "VERTRAUT" }, select: { algorithmus: true, fingerprint: true } },
},
orderBy: { name: "asc" },
}),
prisma.vorlage.findMany({
where: { archiviertAm: null, kategorie: "DIENST" },
select: {
id: true,
name: true,
beschreibung: true,
standardLaufzeitTage: true,
zielbenutzer: true,
zielbenutzerAnlegen: true,
sudoModus: true,
sudoBefehle: true,
authorizedKeysOptionen: true,
sshConfigAlias: true,
knownHostsEintragen: true,
},
orderBy: [{ eingebaut: "desc" }, { name: "asc" }],
}),
]);
return {
hosts: hosts
.filter((h) => h.bootstrapAm !== null)
.map((h) => ({ id: h.id, name: h.name, adresse: h.adresse, port: h.port, hostKeys: h.hostSchluessel })),
vorlagen,
};
}
export type AssistentDaten = Awaited<ReturnType<typeof assistentDaten>>;

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