64 Commits
Author SHA1 Message Date
florian.wilde 573bd1a426 Merge branch 'phase/036-devlog' 2026-10-12 22:52:53 +02:00
florian.wildeandClaude Opus 5.5 7e4968f2ea docs(devlog): Sitzung 8 mit #035 und #036 (#036)
Zeiten und Entscheidungen der Sitzung festhalten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:52:52 +02:00
florian.wilde 29abbf8095 Merge branch 'phase/036-deploymentkey-abbrechen' 2026-10-12 22:52:36 +02:00
florian.wildeandClaude Opus 5.5 5d03681f3c feat(deploymentkey): Rotation vor dem Umstellen abbrechen (#036)
Eine begonnene Rotation ließ sich nicht zurücknehmen; nahm ein Host den
neuen Key dauerhaft nicht an, blieb nur das Übergehen. Abbrechen
verwirft jetzt den neuen Key samt Private Key, ein Dry-Run nimmt ihn
wieder von den Hosts, und eine neue Rotation kann sofort starten.

Nebenbei: Aktionen der Benutzerverwaltung (#035) bekommen im Audit-Log
eine eigene Gruppe, statt den Filter „Anmeldung“ zu verwässern; der
E2E-Test für lokale Konten hängt nicht mehr von der Reihenfolge ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:52:32 +02:00
florian.wilde 68dbc7cadb Merge branch 'phase/035-lokale-konten' 2026-10-12 22:36:30 +02:00
florian.wildeandClaude Opus 5.5 4219701b29 feat(anmeldung): lokale Benutzerkonten mit Pflicht-TOTP (#035)
Ohne authentik gab es nur den Notfall-Admin – ein geteiltes Konto ohne
Rollen und ohne erkennbare Akteure im Audit-Log. Admins legen jetzt
lokale Konten an; sshkv erzeugt ein Einmal-Passwort, das bei der ersten
Anmeldung ersetzt wird, danach ist TOTP Pflicht.

Rollen, Sperren (beendet Sitzungen sofort) und Zurücksetzen unter
Einstellungen → Benutzer, nur für Admins mit frischer Bestätigung.
SSHKV_LOKALE_KONTEN=0 schaltet lokale Konten ab; der Notfall-Admin
bleibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:36:25 +02:00
florian.wilde d9eec3d991 Merge branch 'phase/034-devlog' 2026-10-12 20:11:40 +02:00
florian.wildeandClaude Opus 5.5 b3787f514c docs(devlog): Sitzung 7 mit #033 und #034 (#034)
Zeiten und Entscheidungen festhalten, bevor die Abnahme beginnt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:11:39 +02:00
florian.wilde 91c4880e40 Merge branch 'phase/034-abnahme-checkliste' 2026-10-12 20:11:16 +02:00
florian.wildeandClaude Opus 5.5 dc202594d3 docs(betrieb): Abnahme-Checkliste fuer 1.0.0 (#034)
Die automatischen Tests laufen gegen Mock-OIDC und Testcontainer.
Echter authentik, echte Hosts und der Betrieb brauchen eine eigene,
abhakbare Prüfung mit erwartetem Ergebnis je Punkt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:11:15 +02:00
florian.wilde 525eddb091 Merge branch 'phase/033-erneut-rotieren' 2026-10-12 20:09:48 +02:00
florian.wildeandClaude Opus 5.5 c06c051637 feat(rotation): nach Abbruch erneut rotieren (#033)
Bisher war vorgaengerId eindeutig: nach einer abgebrochenen Rotation
passte kein zweiter Nachfolger in die Kette, und der Schlüssel ließ sich
nur über Neuanlage, Zuweisen und Widerruf ersetzen.

Jetzt ist die Relation 1:n. Ein partieller Index erlaubt je Vorgänger
nur einen Nachfolger, der weder widerrufen ist noch gerade widerrufen
wird. Maßgeblich ist der jüngste. Erst wenn der Abbruch abgeschlossen
ist, beginnt die Rotation wieder bei Schritt 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:09:42 +02:00
florian.wilde c91e882159 Merge branch 'phase/032-abschluss-doku' 2026-10-12 18:08:05 +02:00
florian.wildeandClaude Opus 5.5 d34f8b6f54 docs(devlog): Sitzung 6, #031 und #032 nachgetragen (#032)
Zeiten und Entscheidungen der Sitzung festhalten, damit die Abnahme
von 1.0.0 den vollständigen Stand sieht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:08:03 +02:00
florian.wilde 844161dc1b Merge branch 'phase/032-nacharbeiten' 2026-10-12 17:56:33 +02:00
florian.wildeandClaude Opus 5.5 021e415fde docs(issues): #032 abgeschlossen, Entscheidungen notiert (#032)
Haelt fest, was die Nacharbeiten gefunden und entschieden haben:
den Fehler im Massenwiderruf (UUIDs fielen aus der Auswahl), das
Zustandsmodell des Rotationsabbruchs ohne neues Feld, warum es danach
keinen zweiten Nachfolger gibt, und wie die Protokoll-IDs ohne
Umnummerieren lueckenlos werden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:55:46 +02:00
florian.wildeandClaude Opus 5.5 f18fd4ec06 feat(rotation): laufende Rotation abbrechen (#032)
Bisher liess sich eine angefangene Rotation nur zu Ende fuehren. Wer
merkt, dass der neue Schluessel nicht passt, braucht einen Weg zurueck,
ohne den alten anzufassen.

"Rotation abbrechen" (Rotationsseite und Seite des Nachfolgers) merkt
den neuen Schluessel ueber den bestehenden Widerruf aus #024 vor; er
kann schon ausgerollt sein, deshalb folgen Dry-Run und Anwenden wie
sonst. Der alte bleibt aktiv und unveraendert. Wo beide denselben
Alias tragen, zeigt die config danach wieder auf die alte Datei, weil
privat.ts nur die verbleibenden Zuweisungen in den Block schreibt.

Zustandsmodell: kein neues Feld. Ein Nachfolger im Widerruf (oder
widerrufen) ist eine abgebrochene Rotation; vorgaengerId bleibt fuer
die Historie, der Abbruch steht als schluessel.rotation.abgebrochen im
Protokoll (in derselben Transaktion wie das Vormerken, dafuer nimmt
widerrufEinleiten optional einen Zusatzschritt an). Abbrechen geht nur,
solange der alte Schluessel noch nicht entfernt wird. widerrufAbschliessen
macht aus dem Widerruf des alten Schluessels kein ROTIERT mehr, wenn
der Nachfolger selbst im Widerruf ist. Weil vorgaengerId eindeutig
ist, gibt es nach einem Abbruch keinen zweiten Nachfolger; Seite und
Fehlermeldung sagen das.

Tests: DB-Tests fuer Abbruch, Idempotenz, Sperren und Status;
Integrationstest gegen echte Ziele (neuer Key ausgerollt, abbrechen,
neuer weg, alter in authorized_keys, ssh zu-b von A geht); E2E fuer
den Ablauf im Browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:45:11 +02:00
florian.wildeandClaude Opus 5.5 fb56fd36c5 fix(widerruf): Massenwiderruf nimmt erzeugte Schluessel an (#032)
Der neue E2E-Test (zwei im Browser erzeugte Schluessel mit Public Key
auf Ziel B, Auswahl in der Liste, frische Bestaetigung ueber den
Mock-OIDC mit auth_time, gemeinsamer Dry-Run, Anwenden) fand zwei
Fehler im Produkt:

- Die Massenwiderruf-Seite filterte die IDs aus ?id= mit
  /^[a-z0-9]{1,64}$/. Vom Worker erzeugte Schluessel tragen aber eine
  UUID mit Bindestrichen, nur Prisma-Defaults sind cuids. Damit fiel
  jeder erzeugte Schluessel still aus der Auswahl, die Seite meldete
  "Keine Schluessel gewaehlt". Der Filter steckt jetzt als
  idsAusSuchparameter in src/lib/schluessel.ts, erlaubt Bindestriche
  und hat einen eigenen Test.
- Der Hinweis nach verweigerter Bestaetigung sagte "erneut Widerruf
  vorbereiten waehlen", der Knopf heisst beim Massenwiderruf aber
  "2 Schluessel widerrufen". Er nennt jetzt den Knopf, den es gibt.

Der Test prueft danach, dass beide Keys aus authorized_keys auf B
verschwunden und WIDERRUFEN sind. datenbank() und oidcBestaetigen()
wandern dafuer aus schluessel.spec.ts nach tests/e2e/hilfen.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:44:46 +02:00
florian.wildeandClaude Opus 5.5 be9837ba50 feat(zuweisungen): Zum Dry-Run springt zur Dry-Run-Karte (#032)
Der Link im Zell-Dialog der Zuweisungsmatrix fuehrte nur auf den Kopf
der Schluesselseite; die Dry-Run-Karte liegt weit unten, und wer aus der
Matrix kommt, will genau dorthin. Die Karte traegt jetzt id="dry-run"
(Karte nimmt dafuer eine optionale id an), der Link zeigt auf
/schluessel/<id>#dry-run. scroll-mt haelt die Karte unter der festen
Kopfleiste der schmalen Ansicht.

E2E: der Matrix-Test klickt den Link und prueft Adresse und Sichtbarkeit
der Karte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:29:49 +02:00
florian.wilde 3e0176dc0c Merge branch 'phase/031-deploymentkey-rotation' 2026-10-12 17:27:25 +02:00
florian.wildeandClaude Opus 5.5 cab6f45510 feat(deploymentkey): geführte Rotation des Deployment-Keys (#031)
Bisher ließ sich der Instanz-Key nur von Hand auf jedem Host tauschen.
Die Rotation läuft jetzt in fünf Schritten: Nachfolger erzeugen, per
Dry-Run neben den alten verteilen, Anmeldung je Host prüfen, umstellen,
alten Key aus allen Blöcken entfernen und seinen Private Key verwerfen.

Der Nachfolger steht während der Rotation in Block und Pflichtliste,
damit kein Host ohne funktionierenden Key dasteht. Umgestellt wird nur
nach erfolgreicher Prüfung oder bewusstem Übergehen eines Hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:27:09 +02:00
florian.wildeandClaude Opus 5.5 b4804390c4 fix(protokoll): IDs ohne Zweierschritte vergeben (#032)
Spalten-Default (BIGSERIAL) und Trigger protokoll_verketten zogen je
eine Nummer aus der Sequenz, jede Zeile verbrauchte zwei IDs. Die
Nummer aus dem Default darf der Trigger aber nicht einfach
uebernehmen, denn sie wird vor der Advisory-Sperre gezogen: Eine parallele
Transaktion mit groesserer ID koennte zuerst verketten, und die Kette
liesse sich in ID-Reihenfolge nicht mehr pruefen.

Deshalb zieht nur noch der Trigger unter der Sperre; der Default ist
ein Platzhalter 0 (Prisma: @default(0)), damit create ohne ID
funktioniert. Die Sequenz bleibt der Spalte zugeordnet. Bestehende
Zeilen werden nicht umnummeriert, weil die ID in die Hash-Eingabe
eingeht.

Tests: drei Eintraege nacheinander ergeben n, n+1, n+2; eine
mitgegebene ID wird ueberschrieben; 20 parallele Eintraege belegen
genau 20 aufeinanderfolgende IDs bei intakter Kette.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:19:28 +02:00
florian.wilde 75077f9101 Merge branch 'phase/030-gitea-abgleich-2' 2026-10-12 17:13:18 +02:00
florian.wildeandClaude Opus 5.5 87b733cc9f chore(gitea): Issues nach Gitea, Basic-Auth fuer Konto-Passwort (#030)
In den Git-Zugangsdaten steht das Konto-Passwort, kein API-Token. Gitea
nimmt es nur per Basic-Auth an; das Skript erkennt das jetzt selbst.
Alle 30 Issues mit Labels und Meilensteinen uebertragen. Neu: #031
Rotation des Deployment-Keys und #032 Nacharbeiten fuer 1.0.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:13:15 +02:00
florian.wilde 9759a91e34 Merge branch 'phase/release-0.5.0' 2026-10-12 17:08:19 +02:00
florian.wildeandClaude Opus 5.5 a531857e1f chore(release): 0.5.0 - Phase 5 abgeschlossen
Uebersicht, Audit-Log, Benachrichtigungen, Handbuch und Betrieb.
CHANGELOG, DEVLOG und README nachgetragen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:08:17 +02:00
florian.wildeandClaude Opus 5.5 ea6e44fced test(anmeldung): Namen gezielt im Benutzerkasten pruefen (#026)
Seit die Uebersicht echte letzte Aktionen zeigt, steht der Name des
Mock-Admins mehrfach auf der Seite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:08:06 +02:00
florian.wildeandClaude Opus 5.5 034a10b0b1 fix(protokoll): Texte fuer die Aktionen der Benachrichtigungen (#027)
Der Test, der jede Protokoll-Aktion im Code auf einen lesbaren Text
prueft, schlug nach dem Merge von #028 an - genau dafuer gibt es ihn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:02:54 +02:00
florian.wilde c8f4d8a386 Merge branch 'phase/029-handbuch-abschluss' 2026-10-12 16:56:53 +02:00
florian.wildeandClaude Opus 5.5 d9e218a6d3 docs(anleitung): Kapitel Einstellungen und Benachrichtigungen (#029)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:56:52 +02:00
florian.wilde ca05d6a58e Merge branch 'phase/029-handbuch'
# Conflicts:
#	src/worker/index.ts
2026-10-12 16:56:22 +02:00
florian.wilde b99d19ec8c Merge branch 'phase/028-benachrichtigungen'
# Conflicts:
#	docker-compose.yml
#	scripts/screenshots.ts
2026-10-12 16:55:57 +02:00
florian.wildeandClaude Opus 5.5 3e19b11a80 test(einstellungen): E2E, Screenshots und Issue-Abschluss (#028)
Der E2E-Lauf belegt im gebauten Stand, was die Unit- und DB-Tests nur
einzeln zeigen: ein Admin speichert SMTP, die Testnachricht kommt ueber
den Worker in Mailpit an, das Passwort kehrt nie in die Seite zurueck,
und ein Leser sieht keine Aendern-Knoepfe. Die Screenshots (1440/390 px)
halten das Layout der neuen Seite fuer die Anleitung fest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:54:56 +02:00
florian.wildeandClaude Opus 5.5 8f5b31c0cb feat(benachrichtigung): E-Mail und Webhook bei Drift und Ablauf (#028)
Drift, ablaufende und abgelaufene Schluessel, unerreichbare Hosts und
geaenderte Host-Keys sollen auffallen, ohne dass jemand taeglich in die
Oberflaeche schaut. Versand per SMTP (Microsoft 365, STARTTLS) und per
Webhook mit HMAC-Signatur, eingestellt unter /einstellungen.

Die Anlaesse liest der Worker aus der Datenbank, statt sie im Abgleich
auszuloesen: der Abgleich-Ablauf bleibt unberuehrt, und ein verpasster
Lauf holt sich der naechste. Ein Versandprotokoll (Zustellung) mit
Anlass-Fingerabdruck verhindert Doppelmeldungen und haelt Fehlschlaege
fuer begrenzte Wiederholungen fest. Taeglich eine Zusammenfassung,
geaenderte Host-Keys im Fuenf-Minuten-Takt sofort.

Versand und Testnachricht laufen im Worker, damit der Webprozess keinen
Weg zu Mailserver oder Webhook-Ziel braucht. Passwort und Geheimnis
liegen nur verschluesselt in der Datenbank; die Seite zeigt "gesetzt".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:54:43 +02:00
florian.wildeandClaude Opus 5.5 7721a9f764 docs(anleitung): Kapitel Audit-Log und Syslog-Variablen (#029)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:49:28 +02:00
florian.wilde 461cab1389 Merge branch 'main' into phase/029-handbuch 2026-10-12 16:49:27 +02:00
florian.wilde fb830dffa7 Merge branch 'phase/026-aktionstexte' 2026-10-12 16:49:11 +02:00
florian.wildeandClaude Opus 5.5 ad371b785a feat(uebersicht): lesbare Aktionstexte aus dem Audit-Log (#026)
Die letzten Aktionen nutzen dieselben Texte wie das Audit-Log (#027)
statt technischer Namen wie authorized_keys.geschrieben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:49:07 +02:00
florian.wilde 2a45d4cc46 Merge branch 'phase/027-protokoll-ansicht' 2026-10-12 16:48:27 +02:00
florian.wildeandClaude Opus 5.5 0154343fd2 feat(protokoll): Audit-Log mit Filtern, Kettenpruefung und CSV (#027)
Das Protokoll war bisher nur in der Datenbank lesbar. Die Ansicht zeigt es
jeder Rolle mit lesbaren Aktionstexten, aufklappbarem Diff, Details und
Hashes; ein Test haelt fest, dass jede Aktion im Code einen Text hat.

Geblaettert wird ueber die ID statt OFFSET, weil das Protokoll waehrend
des Lesens waechst. Die Kettenpruefung liest jede Zeile und laeuft deshalb
nur auf Knopfdruck, mit 5 Minuten Zwischenspeicher; ein Bruch wird selbst
protokolliert. Der CSV-Export streamt in Stapeln, entschaerft Formeln und
steht selbst im Protokoll. protokollieren() reicht jeden Eintrag nach dem
Schreiben an Syslog weiter, ohne Details und Diff.

Kein Weg aendert oder loescht Eintraege; ein struktureller Test sichert
das ab, zusaetzlich zum Trigger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:47:44 +02:00
florian.wildeandClaude Opus 5.5 7bb2ca6a51 feat(protokoll): Syslog-Sender nach RFC 5424 ohne Abhaengigkeit (#027)
Die Kopie beim Syslog-Server ist der vom sshkv-Host unabhaengige Beleg:
wer die Datenbank uebernimmt, kann die Hash-Kette neu rechnen, aber nicht
zurueckholen, was schon beim Syslog liegt. Eigene kleine Implementierung
mit node:dgram/node:net statt einer Bibliothek, weil das Format
ueberschaubar ist und jede Abhaengigkeit im Sicherheitskern zaehlt.

Strukturierte Daten werden nach RFC 5424 escaped und begrenzt (2048 Byte),
Severity folgt dem Ergebnis, Facility standardmaessig authpriv. Der Sender
wirft nie und meldet Fehler hoechstens einmal je Minute, damit ein
ausgefallener Syslog-Server weder Aktionen verhindert noch das Log flutet.
Web und Worker bekommen die Variablen, weil beide protokollieren.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:47:30 +02:00
florian.wildeandClaude Opus 5.5 3b28f73c95 docs(betrieb): Handbuch, Bedrohungsanalyse, geprobte Sicherung (#029)
Handbuch mit Bildern fuer alle Ablaeufe, darunter der Kern-
Anwendungsfall und der Deployment-Zugang fuer Claude Code.
Betriebsdoku zu Installation, authentik und Reverse Proxy sowie eine
Bedrohungsanalyse mit Notfallplan fuer den Fall, dass sshkv selbst
kompromittiert wird.

Sicherung und Wiederherstellung als Skripte, auf einem Wegwerf-Stapel
geprobt. Dabei fielen zwei Fehler auf: das Schema der Warteschlange
liess sich nicht zurueckspielen, und ein Abbruch ohne Transaktion
loeschte es halb. Jetzt nur Schema public, in einer Transaktion, mit
Pruefwert des Master-Keys. Der Worker startet nicht, wenn der
Master-Key nicht zur Datenbank passt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:46:38 +02:00
florian.wilde f791816e85 Merge branch 'phase/026-uebersicht' 2026-10-12 16:33:14 +02:00
florian.wildeandClaude Opus 5.5 ca61ca5b41 feat(uebersicht): echte Zahlen statt Beispieldaten (#026)
Kennzahlen, Hosts mit Abweichung, bald ablaufende Schluessel und letzte
Aktionen kommen aus der Datenbank; die Beispieldaten des Prototyps sind
entfernt. Befunde werden je Host und Benutzer in fester Reihenfolge
zusammengefasst, abgelaufene und noch ausgerollte Schluessel stehen in
Koralle. Anmeldungen bleiben dem Audit-Log vorbehalten, sonst
verdraengten sie auf der Uebersicht alles andere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:33:11 +02:00
florian.wilde 83bd9e0922 Merge branch 'phase/release-0.4.0' 2026-10-12 13:40:34 +02:00
florian.wildeandClaude Opus 5.5 18f72f9d03 chore(release): 0.4.0 - Phase 4 abgeschlossen
Abgleich und Drift-Erkennung, Import, Rotation, Widerruf und
Zuweisungsmatrix. CHANGELOG und DEVLOG nachgetragen.

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

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

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

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

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

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

Migration 20261012130000_widerruf_eingeleitet.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:02:11 +02:00
florian.wilde 2dad8e5631 Merge branch 'phase/release-0.3.0' 2026-10-12 11:25:17 +02:00
florian.wildeandClaude Opus 5.5 2c658b1223 chore(release): 0.3.0 - Phase 3 abgeschlossen
Host-zu-Host-Assistent, Zielkonten mit sudoers, Vorlagen. CHANGELOG
und DEVLOG nachgetragen.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:16:31 +02:00
228 changed files with 19362 additions and 465 deletions
+30
View File
@@ -51,6 +51,12 @@ SSHKV_OIDC_GROUP_READER=sshkv-readers
# Nur Entwicklung: OIDC ueber http erlauben (Mock-Anbieter). Im Betrieb nie setzen. # Nur Entwicklung: OIDC ueber http erlauben (Mock-Anbieter). Im Betrieb nie setzen.
# SSHKV_OIDC_HTTP_ERLAUBT=1 # SSHKV_OIDC_HTTP_ERLAUBT=1
# --- Lokale Konten (#035) -------------------------------------------------------
# Admins legen unter /einstellungen/benutzer Konten mit Passwort und Pflicht-TOTP an – neben
# oder statt authentik. 0 schaltet sie ab (vorhandene lokale Konten kommen dann nicht mehr
# hinein); der Notfall-Admin bleibt immer.
SSHKV_LOKALE_KONTEN=1
# --- Notfall-Admin ------------------------------------------------------------- # --- Notfall-Admin -------------------------------------------------------------
# Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet. # Wird beim ersten Seed angelegt. TOTP wird bei der ersten Anmeldung eingerichtet.
SSHKV_BREAKGLASS_USER=notfall SSHKV_BREAKGLASS_USER=notfall
@@ -59,6 +65,25 @@ SSHKV_BREAKGLASS_PASSWORD=
# --- Worker -------------------------------------------------------------------- # --- Worker --------------------------------------------------------------------
# Zeitplan fuer den Abgleich aller Hosts (cron) # Zeitplan fuer den Abgleich aller Hosts (cron)
SSHKV_SYNC_CRON=30 2 * * * SSHKV_SYNC_CRON=30 2 * * *
# Taegliche Zusammenfassung der Benachrichtigungen (cron, Europe/Berlin, #028). Geaenderte
# Host-Keys meldet der Worker unabhaengig davon alle fuenf Minuten. Kanaele und Anlaesse
# stellt ein Admin unter /einstellungen ein.
SSHKV_MELDUNG_CRON=0 7 * * *
# --- Syslog (optional, #027) ---------------------------------------------------
# Jeder Protokolleintrag geht zusaetzlich als RFC-5424-Zeile an einen Syslog-Server – die
# Kopie ausserhalb von sshkv. Ohne SSHKV_SYSLOG_HOST ist die Weiterleitung aus. Es gehen nur
# Akteur, Aktion, Ergebnis, Host, Zielbenutzer, Fingerprint und Hash hinaus, nie Details/Diff.
# Web und Worker protokollieren beide; beide brauchen die Variablen.
# SSHKV_SYSLOG_HOST=syslog.intern
# Standard 514
# SSHKV_SYSLOG_PORT=514
# udp (Standard) oder tcp (Oktettzaehlung nach RFC 6587)
# SSHKV_SYSLOG_PROTOKOLL=udp
# APP-NAME im Syslog-Kopf (Standard sshkv)
# SSHKV_SYSLOG_APPNAME=sshkv
# Facility (Standard authpriv; auch auth, daemon, user, local0 … local7)
# SSHKV_SYSLOG_FACILITY=authpriv
# --- Integrationstests (#011) --------------------------------------------------- # --- Integrationstests (#011) ---------------------------------------------------
# SSH-Testziele aus docker-compose.test.yml. Die Compose-Datei liest dieselben Variablen # SSH-Testziele aus docker-compose.test.yml. Die Compose-Datei liest dieselben Variablen
@@ -70,3 +95,8 @@ SSHKV_TEST_ROOT_PASSWORT=sshkv-test
SSHKV_TEST_TEST_PASSWORT=sshkv-test SSHKV_TEST_TEST_PASSWORT=sshkv-test
# Mock-OIDC (Port-Bindung) # Mock-OIDC (Port-Bindung)
SSHKV_TEST_OIDC=127.0.0.1:8090 SSHKV_TEST_OIDC=127.0.0.1:8090
# Mailpit fuer tests/integration/benachrichtigung.test.ts (#028), getrennt zu starten:
# docker run -d --name sshkv-mailpit -e MP_SMTP_DISABLE_RDNS=true \
# -p 127.0.0.1:1026:1025 -p 127.0.0.1:8026:8025 axllent/mailpit
SSHKV_TEST_SMTP=127.0.0.1:1026
SSHKV_TEST_MAILPIT_API=http://127.0.0.1:8026
+62
View File
@@ -8,13 +8,75 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
### Hinzugefügt ### Hinzugefügt
- Geführte Rotation des Deployment-Keys in fünf Schritten (erzeugen, verteilen, prüfen, umstellen, aufräumen) – kein Host steht dabei ohne funktionierenden Key da; nicht erreichbare Hosts lassen sich bewusst übergehen (#031)
- Laufende Schlüsselrotation abbrechen – der Nachfolger wird per Dry-Run wieder entfernt (#032)
- „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032)
- Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (#033)
- Rotation des Deployment-Keys lässt sich vor dem Umstellen abbrechen; der neue Key wird verworfen und per Dry-Run wieder entfernt (#036)
- Lokale Konten mit Passwort und Pflicht-TOTP neben oder statt authentik: Admins legen sie unter Einstellungen → Benutzer an (Einmal-Passwort, Rollen, Sperren, Zurücksetzen); abschaltbar mit `SSHKV_LOKALE_KONTEN=0` (#035)
### Geändert ### Geändert
- Protokoll-IDs werden lückenlos vergeben (#032)
### Behoben ### Behoben
- Aktionen der Benutzerverwaltung erscheinen im Audit-Log unter einer eigenen Gruppe „Benutzer“ statt unter „Anmeldung“ (#035)
- Massenwiderruf lehnte im Browser erzeugte Schlüssel ab (IDs mit Bindestrich) (#032)
- Hinweis nach verweigerter Bestätigung nannte beim Massenwiderruf den falschen Knopf (#032)
### Sicherheit ### Sicherheit
## [0.5.0] – 2026-10-12
Phase 5: Übersicht, Audit-Log, Benachrichtigungen, Handbuch und Betrieb.
### Hinzugefügt
- Übersicht mit echten Zahlen: Kennzahlen, Hosts mit Abweichung, bald ablaufende und abgelaufene Schlüssel, letzte Aktionen (#026)
- Audit-Log: Filter, aufklappbare Einträge mit Diff und Hashes, Prüfung der Hash-Kette, CSV-Export (gestreamt), optionale Syslog-Kopie nach RFC 5424 (#027)
- Benachrichtigungen per E-Mail (Microsoft 365 SMTP vorbelegt) und Webhook mit Signatur: tägliche Zusammenfassung zu Drift, Ablauf und nicht erreichbaren Hosts, geänderte Host-Keys sofort; Testversand, Versandprotokoll ohne Doppelmeldungen; Einstellungsseite (#028)
- Handbuch mit Bildern, Installation mit authentik und Reverse Proxy, Bedrohungsanalyse mit Notfallplan (#029)
- `deploy/sichern.sh` und `deploy/wiederherstellen.sh` – Wiederherstellung auf einem Wegwerf-Stapel geprobt (#029)
### Geändert
- Der Worker startet nicht, wenn der Master-Key nicht zur Datenbank passt (#029)
### Sicherheit
- Sicherung ohne Master-Key, nur mit Prüfwert; Wiederherstellung bricht vor jeder Änderung ab, wenn der Master-Key nicht passt, und läuft in einer Transaktion
- SMTP-Passwort und Webhook-Geheimnis verschlüsselt gespeichert und nie wieder angezeigt; Webhook nur über https, ohne Weiterleitungen
- Syslog-Kopie enthält nie Details oder Diffs
## [0.4.0] – 2026-10-12
Phase 4: Abgleich, Import, Rotation, Widerruf und Zuweisungsmatrix.
### Hinzugefügt
- Abgleich je Host und nächtlich per Zeitplan (`SSHKV_SYNC_CRON`): erkennt fehlende Schlüssel, von Hand geänderte Blöcke, unbekannte Schlüssel, falsche Rechte, beschädigte Blöcke und veränderte Private Keys; Befunde öffnen und schließen sich selbst. Hostseite mit Abweichungen und Ist-Zustand je Benutzer, „Jetzt abgleichen“ auch auf der Übersicht (#021)
- Unbekannte Schlüssel übernehmen: als bekannt aufnehmen oder per Dry-Run in den verwalteten Block holen (#022)
- Geführte Rotation: neuer Schlüssel an allen Stellen, Verbindungstests, erst dann wird der alte entfernt; jederzeit fortsetzbar, Rotationskette auf der Schlüsselseite (#023)
- Widerruf mit einem Klick und Massenwiderruf: Public Key, Private Key, config- und known_hosts-Einträge entfernen, optional Zielkonten; Zwischenzustand, solange ein Host nicht erreicht wird; abgelaufene Schlüssel mit Hinweis (#024)
- Zuweisungsmatrix Schlüssel × Host mit Filtern, Zell-Dialog zum Zuweisen und CSV-Export (#025)
- Skript zum Abgleich der Issues nach Gitea (`pnpm issues:sync`, #030 – Zugang steht noch aus)
### Sicherheit
- Abgleich liest Dateien als der jeweilige Benutzer und ändert auf dem Host nichts
- Zeilen außerhalb des verwalteten Blocks entfernt sshkv nur bei ausdrücklich übernommenen Schlüsseln
- Ein Widerruf gilt erst als abgeschlossen, wenn der Schlüssel wirklich überall entfernt ist; eine Rotation entfernt den alten Schlüssel erst nach erfolgreichen Verbindungstests
- CSV-Export entschärft Werte, die eine Tabellenkalkulation als Formel lesen würde
## [0.3.0] – 2026-10-12
Phase 3: Host-zu-Host – Private Keys, Zielkonten, Vorlagen und Assistent.
### Hinzugefügt
- Host-zu-Host-Assistent: Vorlage wählen, Host A und B, Zielbenutzer, Einschränkungen und `~/.ssh/config`-Alias, Fingerprint von B bestätigen, Dry-Run, ausrollen, Verbindung testen – in einem Durchgang (#020)
- Zielbenutzer (z. B. `deploy`) auf dem Ziel anlegen, mit sudoers-Datei für bestimmte Befehle oder vollen Zugriff; Vorschau vor dem Anlegen, Prüfung mit `visudo -c` (#018)
- Vorlagen verwalten: eingebaute „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“, eigene anlegen und archivieren, Live-Vorschau der Platzhalter und des sudoers-Inhalts (#019)
- Übersicht verlinkt den Assistenten
### Sicherheit
- Neue Benutzer mit sudo nur durch Admins mit frischer Bestätigung
- sshkv überschreibt keine fremde sudoers-Datei und fasst Systemkonten nicht an; ein Widerruf löscht nur Benutzer, die sshkv selbst angelegt hat
- Platzhalter-Werte können keine zusätzlichen authorized_keys-Optionen einschleusen
## [0.2.0] – 2026-10-12 ## [0.2.0] – 2026-10-12
Phase 2: Schlüssel, Personen und Ausrollen von Public Keys – dazu vorgezogen aus Phase 3 das Ausrollen von Private Keys. Phase 2: Schlüssel, Personen und Ausrollen von Public Keys – dazu vorgezogen aus Phase 3 das Ausrollen von Private Keys.
+123 -5
View File
@@ -2,8 +2,8 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt (Wandzeit):** 2 h 25 min **Gesamt (Wandzeit):** 6 h 40 min
**Summe der Issue-Zeiten:** 7 h 08 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011 und #012–#014. **Summe der Issue-Zeiten:** 16 h 38 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032.
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -25,14 +25,34 @@
| #015 authorized_keys-Parser | 0 h 15 min | | #015 authorized_keys-Parser | 0 h 15 min |
| #016 Public Key ausrollen | 0 h 40 min | | #016 Public Key ausrollen | 0 h 40 min |
| #017 Private Key ausrollen | 0 h 45 min | | #017 Private Key ausrollen | 0 h 45 min |
| #018 Zielbenutzer und sudoers (Teil) | 0 h 10 min | | #018 Zielbenutzer und sudoers | 0 h 30 min |
| #019 Vorlagen (Teil) | 0 h 10 min | | #019 Vorlagen | 0 h 20 min |
| #020 Verbindungstest (Teil) | 0 h 05 min | | #020 Host-zu-Host-Assistent | 0 h 50 min |
| #021 Abgleich und Drift-Erkennung | 0 h 45 min |
| #022 Import bestehender Schlüssel | 0 h 20 min |
| #023 Geführte Rotation | 0 h 20 min |
| #024 Widerruf und Massenwiderruf | 0 h 25 min |
| #025 Zuweisungsmatrix | 0 h 30 min |
| #026 Übersicht | 0 h 25 min |
| #027 Protokollansicht, CSV, Syslog | 0 h 20 min |
| #028 Benachrichtigungen | 0 h 30 min |
| #029 Handbuch und Betriebsdokumentation | 0 h 50 min |
| #030 Gitea-Abgleich | 0 h 05 min |
| #031 Rotation des Deployment-Keys | 1 h 10 min |
| #032 Nacharbeiten Phase 4/5 | 0 h 45 min |
| #033 Erneut rotieren nach Abbruch | 0 h 20 min |
| #034 Abnahme-Checkliste | 0 h 10 min |
| #035 Lokale Benutzerkonten | 0 h 35 min |
| #036 Deployment-Key-Rotation abbrechen | 0 h 20 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
| v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen** | v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen**
| v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen** | v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen**
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
| v1.0.0 | 2 h 15 min (Wandzeit) | in Arbeit – Abnahme steht aus
--- ---
@@ -84,3 +104,101 @@
- **Private-Key-Ziele im Dry-Run nur für Admins:** Sonst hätte ein Operator einen vorbereiteten Private Key ausrollen lassen können (Freigabe Frage 3). - **Private-Key-Ziele im Dry-Run nur für Admins:** Sonst hätte ein Operator einen vorbereiteten Private Key ausrollen lassen können (Freigabe Frage 3).
- **Keine Sicherungskopie alter Private Keys** auf dem Host; config-Block am Dateianfang, weil ssh den ersten Treffer nimmt. - **Keine Sicherungskopie alter Private Keys** auf dem Host; config-Block am Dateianfang, weil ssh den ersten Treffer nimmt.
- **Ein Hook `useVorgang` für alle:** zwei parallel entstandene Fassungen zusammengeführt, beide Schnittstellen bleiben. - **Ein Hook `useVorgang` für alle:** zwei parallel entstandene Fassungen zusammengeführt, beide Schnittstellen bleiben.
## 2026-10-12 · Sitzung 3 · 11:06–11:36
**Issue:** #018–#020
**Version:** 0.3.0
### Erledigt
- #019 Vorlagen-Oberfläche (Liste, Formular, Live-Vorschau) von einem Sub-Agenten, gemergt
- #018 Zielkonten als Vorgänge mit Migration, Herkunft gemerkt; Integrationstest anlegen → entfernen
- #020 Host-zu-Host-Assistent mit Vorlagen, wiederaufnehmbarer Ausführung und Verbindungstest; E2E mit „Deployment-Zugang“
- Stand: 423 Unit-/DB-Tests, 31 Integrationstests, 16 E2E-Abläufe grün
### Entscheidungen
- **Migration auf Ortszeit-Reihenfolge umbenannt** (`20261012110000`): Prisma vergibt UTC-Zeitstempel, die Migrationen des Agenten trugen Ortszeit – sonst stünde die neue vor älteren.
- **Bestätigung im neuen Tab:** Der Assistent hält seine Eingaben im Speicher; eine Umleitung nach `/bestaetigen` hätte sie gelöscht.
- **Ausführung des Assistenten als Kette im Klick** statt als Hook-Zustandsmaschine – einfacher, und erledigte Schritte lassen sich beim Fortsetzen überspringen.
- **Prozesse stoppen nur über ein Skript, das Programm und Arbeitsverzeichnis prüft:** Zweimal hatte ein zu breites Suchmuster die eigene Shell getroffen.
## 2026-10-12 · Sitzung 4 · 12:50–13:35
**Issue:** #021–#025, #030
**Version:** 0.4.0
### Erledigt
- Zweites, getrenntes Paar SSH-Testziele (`sshkv-test-b`, Ports 2231/2232, Mock-OIDC 8091) für den Rotation/Widerruf-Agenten, damit Integrationstests sich nicht gegenseitig zurücksetzen
- #021 Abgleich mit reiner Befundlogik, Worker-Ablauf, Zeitplan; #022 Übernahme mit neuem Feld `ausserhalbEntfernen` – Integrationstests und E2E
- #023/#024 Rotation und Widerruf (Sub-Agent) mit Zwischenzustand `widerrufEingeleitetAm`; #025 Zuweisungsmatrix (Sub-Agent)
- #030 Gitea-Skript bereitgelegt (Trockenlauf läuft)
- Stand: 470 Unit-/DB-Tests, 38 Integrationstests, 21 E2E-Abläufe grün
### Entscheidungen
- **Abgleich ändert nichts auf dem Host:** Er meldet nur; Korrekturen laufen über Dry-Run und Ausrollen, damit ein Mensch sie sieht.
- **Bekannte Schlüssel außerhalb des Blocks sind kein Befund** – nur unbekannte. Sonst meldete jeder bewusst stehen gelassene Admin-Key dauerhaft Drift.
- **Widerruf mit Zwischenzustand:** Ein Schlüssel wird erst WIDERRUFEN, wenn er überall entfernt ist; ein nicht erreichbarer Host lässt ihn erkennbar „unvollständig“.
- **Rotation entfernt den alten Schlüssel erst nach erfolgreichen Verbindungstests**; doppelte Aliase in `~/.ssh/config`: die jüngste Zuweisung gewinnt.
- **Migrationszeitstempel in Ortszeit-Reihenfolge** auch bei parallel entstandenen Migrationen (`…130000` Agent, `…131000` Hauptsitzung).
## 2026-10-12 · Sitzung 5 · 16:27–17:12
**Issue:** #026–#029
**Version:** 0.5.0
### Erledigt
- #026 Übersicht aus der Datenbank; lesbare Aktionstexte aus #027
- #027 Audit-Log-Ansicht, Kettenprüfung, CSV, Syslog (Sub-Agent)
- #028 Benachrichtigungen und Einstellungsseite (Sub-Agent, eigener Mailpit und eigene Testziele)
- #029 Handbuch (12 Kapitel mit Bildern), Installation, Bedrohungsanalyse, Sicherung; Sicherung/Wiederherstellung geprobt
- Merge-Konflikte (Compose, Screenshot-Skript, Worker-Einstieg) zusammengeführt; der strukturelle Test für Aktionstexte fand vier neue Aktionen ohne Text
- Stand: 858 Unit-/DB-Tests, 43 Integrationstests, 24 E2E-Abläufe grün
### Entscheidungen
- **Wiederherstellung wirklich geprobt** statt nur beschrieben – sie fand zwei Fehler, die im Ernstfall die Wiederherstellung verhindert hätten.
- **Master-Key nicht in die Sicherung,** nur ein Prüfwert; Worker verweigert den Start mit falschem Master-Key.
- **Benachrichtigungen lesen ihre Anlässe aus der Datenbank** statt sich in den Abgleich einzuhängen; Doppelmeldungen verhindert ein Versandprotokoll mit Fingerabdruck je Anlass.
- **Keine 1.0.0 ohne Abnahme:** 0.5.0 schließt die fünf Phasen; authentik, Gitea und ein Lauf auf echten Hosts stehen noch aus.
## 2026-10-12 · Sitzung 6 · 17:12–18:07
**Issue:** #030–#032
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
### Erledigt
- Remote-Repo in Gitea: `main` und Tags v0.1.0–v0.5.0 gepusht, Issues abgeglichen (Basic-Auth, weil die hinterlegten Zugangsdaten ein Passwort sind)
- #031 Geführte Rotation des Deployment-Keys: Service, Worker-Schritte, Seite mit Schrittleiste, Integrationstest gegen beide Testziele, E2E
- #032 Nacharbeiten (Sub-Agent): lückenlose Protokoll-IDs, Rotation abbrechen, Anker „Zum Dry-Run“, Massenwiderruf-E2E – der fand, dass der Massenwiderruf UUIDs mit Bindestrich abwies
- Stand: 876 Unit-/DB-Tests, 46 Integrationstests, 27 E2E-Abläufe grün
### Entscheidungen
- **Der Nachfolger des Deployment-Keys ist ein eigener Schlüssel,** der während der Rotation über `sollFuer()` in Block und Pflichtliste steht – so verteilt ihn der normale Dry-Run, und der Aussperrschutz deckt beide Keys.
- **Umstellen erst nach Prüfung** auf allen Hosts; ein Host lässt sich nur bewusst und protokolliert übergehen.
- **Rotation abbrechen nutzt den vorhandenen Widerruf** statt eines neuen Zustands (#032).
- **Protokoll-ID nur noch aus dem Trigger** unter der Advisory-Sperre – keine Lücken mehr durch verworfene Sequenzwerte.
## 2026-10-12 · Sitzung 7 · 19:48–20:18
**Issue:** #033, #034
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
### Erledigt
- #033 Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (1:n-Relation, partieller Index)
- #034 Abnahme-Checkliste `docs/betrieb/abnahme.md` für authentik, echte Hosts und Betrieb
- Stand: 877 Unit-/DB-Tests, 46 Integrationstests grün; E2E Rotation, Widerruf, Schlüssel grün
### Entscheidungen
- **Je Vorgänger höchstens ein offener Nachfolger** statt genau einem: abgebrochene bleiben in der Historie, maßgeblich ist der jüngste.
- **Neustart erst nach abgeschlossenem Abbruch** – sonst stünden zwei neue Schlüssel gleichzeitig auf den Hosts.
- **Bestätigung „Rotation abgebrochen“ bleibt sichtbar** über dem Neustart, statt kommentarlos auf Schritt 1 zu springen (fand der E2E-Test).
## 2026-10-12 · Sitzung 8 · 22:05–22:55
**Issue:** #035, #036
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
### Erledigt
- #035 Lokale Benutzerkonten mit Pflicht-TOTP: Einmal-Passwort, eigenes Passwort bei der ersten Anmeldung, Benutzerverwaltung unter Einstellungen, Schalter `SSHKV_LOKALE_KONTEN`
- #036 Rotation des Deployment-Keys vor dem Umstellen abbrechen
- Stand: 911 Unit-/DB-Tests, 47 Integrationstests, 29 E2E-Abläufe grün
### Entscheidungen
- **TOTP auch für lokale Konten Pflicht; lokale Konten dauerhaft, aber abschaltbar** (Florian).
- **Admins wählen keine Passwörter für andere** – sshkv erzeugt ein Einmal-Passwort, das genau einmal angezeigt wird.
- **`SSHKV_LOKALE_KONTEN` muss in `docker-compose.yml` stehen:** Compose reicht nur ausdrücklich genannte Variablen an den Web-Container weiter – sonst hätte der Schalter im Betrieb nicht gewirkt.
- **Testreste machen Produktlücken sichtbar:** Eine hängengebliebene Rotation auf verwaisten Testhosts zeigte, dass es keinen Weg zurück gab (#036). Die E2E-Tests räumen solche Reste jetzt selbst ab.
+13 -4
View File
@@ -4,9 +4,9 @@ Selbst gehostete Weboberfläche zur zentralen Verwaltung von SSH-Schlüsseln: Sc
erzeugen und verwalten, Public Keys in `authorized_keys` verteilen, Private Keys gezielt auf erzeugen und verwalten, Public Keys in `authorized_keys` verteilen, Private Keys gezielt auf
andere Hosts ausrollen, Ist- und Soll-Zustand abgleichen – alles revisionssicher protokolliert. andere Hosts ausrollen, Ist- und Soll-Zustand abgleichen – alles revisionssicher protokolliert.
> **Stand 0.1.0 (Phase 1):** Grundgerüst, Anmeldung (OIDC + Notfall-Admin mit TOTP), Hosts und > **Stand 0.5.0:** alle fünf Phasen aus [`docs/PLAN.md`](docs/PLAN.md) umgesetzt – Hosts mit Host-Key-Prüfung
> Hostgruppen, Host-Key-Prüfung (TOFU) und Bootstrap neuer Hosts. Schlüsselverwaltung und > und Bootstrap, Schlüssel, Ausrollen von Public und Private Keys mit Dry-Run, Host-zu-Host-Assistent,
> Ausrollen folgen in Phase 2 und 3 – siehe [`docs/PLAN.md`](docs/PLAN.md). > Abgleich, Rotation, Widerruf, Zuweisungsmatrix, Audit-Log, Benachrichtigungen. 1.0.0 nach der Abnahme.
## Sicherheit vorab ## Sicherheit vorab
@@ -18,7 +18,16 @@ SSHKeyVault hat **root-Zugriff auf alle verwalteten Hosts**. Deshalb:
- Private Keys liegen nur verschlüsselt in der Datenbank (AES-256-GCM, Envelope Encryption); - Private Keys liegen nur verschlüsselt in der Datenbank (AES-256-GCM, Envelope Encryption);
entschlüsseln kann ausschließlich der Worker, der Webprozess hat den Master-Key nicht. entschlüsseln kann ausschließlich der Worker, der Webprozess hat den Master-Key nicht.
Mehr dazu in [`docs/PLAN.md`](docs/PLAN.md), Abschnitt 3. Mehr dazu in [`docs/betrieb/sicherheit.md`](docs/betrieb/sicherheit.md) (mit Bedrohungsanalyse und
Notfallplan) und [`docs/betrieb/sicherung.md`](docs/betrieb/sicherung.md).
## Dokumentation
- [Handbuch](docs/anleitung/README.md) – Bedienung Schritt für Schritt mit Bildern
- [Installation und Betrieb](docs/betrieb/installation.md) – Docker Compose, authentik, Reverse Proxy
- [Sicherung und Wiederherstellung](docs/betrieb/sicherung.md) – `deploy/sichern.sh`, `deploy/wiederherstellen.sh`
- [Sicherheit](docs/betrieb/sicherheit.md) – Aufbau, Bedrohungsanalyse, Notfallplan
- [Abnahme vor 1.0.0](docs/betrieb/abnahme.md) – Prüfliste für authentik, echte Hosts und Betrieb
## Installation (Docker Compose) ## Installation (Docker Compose)
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# SSHKeyVault sichern (#029).
#
# bash deploy/sichern.sh # nach /var/backups/sshkv
# bash deploy/sichern.sh /mnt/sicherung/sshkv # anderes Ziel
#
# Sichert die Datenbank (pg_dump mit --clean, damit sie sich auch in eine nicht leere
# Datenbank zurueckspielen laesst) und legt einen Pruefwert des Master-Keys daneben.
#
# Den MASTER-KEY selbst sichert dieses Skript absichtlich NICHT mit: Datenbank und Master-Key
# zusammen geben alle Private Keys preis. Den Master-Key einmalig und GETRENNT aufbewahren
# (Passwortmanager, Tresor, zweites Medium) – ohne ihn ist jede Sicherung wertlos.
# Siehe docs/betrieb/sicherung.md.
set -o errexit
set -o nounset
set -o pipefail
VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$VERZEICHNIS"
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
gelb() { printf '\033[1;33m! %s\033[0m\n' "$*"; }
fehler() {
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
exit 1
}
ZIEL="${1:-/var/backups/sshkv}"
BEHALTEN="${SSHKV_SICHERUNGEN_BEHALTEN:-14}"
STAND=$(date +%Y%m%d-%H%M%S)
mkdir -p "$ZIEL"
chmod 0700 "$ZIEL"
# Datenbank: als Klartext-SQL, gzip-komprimiert. Enthaelt nur verschluesselte Private Keys.
# Nur das Schema public: Die Warteschlange (Schema pgboss) legt der Worker beim Start neu an,
# und ihre partitionierten Tabellen lassen sich mit --clean nicht zurueckspielen (in der
# Wiederherstellungsprobe aufgefallen).
docker compose exec -T db pg_dump -U sshkv --clean --if-exists --no-owner --schema=public sshkv \
| gzip -9 > "${ZIEL}/datenbank-${STAND}.sql.gz" \
|| fehler "pg_dump ist fehlgeschlagen. Läuft der Dienst db?"
[ -s "${ZIEL}/datenbank-${STAND}.sql.gz" ] || fehler "Die Sicherung ist leer."
chmod 0600 "${ZIEL}/datenbank-${STAND}.sql.gz"
gruen "Datenbank gesichert: ${ZIEL}/datenbank-${STAND}.sql.gz"
# Pruefwert des Master-Keys: verraet nichts ueber den Schluessel, zeigt beim Zurueckspielen
# aber, ob der richtige Master-Key bereitliegt.
ENV_DATEI="${SSHKV_ENV_DATEI:-.env}"
SCHLUESSEL="$(grep -E '^SSHKV_MASTER_KEY_HOST_FILE=' "$ENV_DATEI" 2>/dev/null | cut -d= -f2- || true)"
SCHLUESSEL="${SCHLUESSEL:-./geheim/master.key}"
if [ -r "$SCHLUESSEL" ]; then
sha256sum "$SCHLUESSEL" | cut -d' ' -f1 > "${ZIEL}/master-key-pruefwert-${STAND}.txt"
chmod 0600 "${ZIEL}/master-key-pruefwert-${STAND}.txt"
gruen "Prüfwert des Master-Keys abgelegt (nicht der Schlüssel selbst)."
else
gelb "Master-Key ${SCHLUESSEL} nicht lesbar – kein Prüfwert abgelegt (mit sudo ausführen?)."
fi
# Alte Sicherungen aufraeumen (samt Pruefwert).
ls -1t "${ZIEL}"/datenbank-*.sql.gz 2>/dev/null | tail -n +"$((BEHALTEN + 1))" | while read -r alt; do
alter_stand=$(basename "$alt" .sql.gz)
alter_stand=${alter_stand#datenbank-}
rm -f -- "$alt" "${ZIEL}/master-key-pruefwert-${alter_stand}.txt"
done
gelb "Den Master-Key getrennt aufbewahren – er gehört NICHT neben diese Sicherung."
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# SSHKeyVault aus einer Sicherung wiederherstellen (#029).
#
# bash deploy/wiederherstellen.sh /var/backups/sshkv # jüngste Sicherung
# bash deploy/wiederherstellen.sh /var/backups/sshkv 20261012-170000
# bash deploy/wiederherstellen.sh … --ja # ohne Rückfrage
#
# Voraussetzung: Der zur Sicherung gehoerende Master-Key liegt an der in .env genannten Stelle
# (SSHKV_MASTER_KEY_HOST_FILE). Das Skript vergleicht ihn mit dem Pruefwert der Sicherung und
# bricht ab, wenn er nicht passt – sonst laeuft sshkv ohne lesbare Private Keys an.
set -o errexit
set -o nounset
set -o pipefail
VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$VERZEICHNIS"
blau() { printf '\033[1;36m› %s\033[0m\n' "$*"; }
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
fehler() {
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
exit 1
}
QUELLE="${1:-/var/backups/sshkv}"
STAND=""
OHNE_RUECKFRAGE="nein"
for argument in "${@:2}"; do
if [ "$argument" = "--ja" ]; then OHNE_RUECKFRAGE="ja"; else STAND="$argument"; fi
done
[ -d "$QUELLE" ] || fehler "Kein Sicherungsordner unter ${QUELLE}."
if [ -z "$STAND" ]; then
DATENBANK=$(ls -1t "${QUELLE}"/datenbank-*.sql.gz 2>/dev/null | head -n1 || true)
else
DATENBANK="${QUELLE}/datenbank-${STAND}.sql.gz"
fi
[ -n "${DATENBANK:-}" ] && [ -f "$DATENBANK" ] || fehler "Keine Datenbanksicherung in ${QUELLE} gefunden."
PRUEFWERT="${DATENBANK/datenbank-/master-key-pruefwert-}"
PRUEFWERT="${PRUEFWERT/.sql.gz/.txt}"
ENV_DATEI="${SSHKV_ENV_DATEI:-.env}"
SCHLUESSEL="$(grep -E '^SSHKV_MASTER_KEY_HOST_FILE=' "$ENV_DATEI" 2>/dev/null | cut -d= -f2- || true)"
SCHLUESSEL="${SCHLUESSEL:-./geheim/master.key}"
if [ -f "$PRUEFWERT" ]; then
[ -r "$SCHLUESSEL" ] || fehler "Master-Key ${SCHLUESSEL} nicht lesbar (mit sudo ausführen?)."
[ "$(sha256sum "$SCHLUESSEL" | cut -d' ' -f1)" = "$(cat "$PRUEFWERT")" ] \
|| fehler "Der Master-Key unter ${SCHLUESSEL} gehört NICHT zu dieser Sicherung. Abbruch."
gruen "Master-Key passt zur Sicherung."
else
printf '\033[1;33m! Kein Prüfwert zur Sicherung – der Worker prüft den Master-Key beim Start.\033[0m\n'
fi
printf '\033[1;33m! Achtung: Der aktuelle Datenbestand wird durch die Sicherung ersetzt.\033[0m\n'
echo " Sicherung: $(basename "$DATENBANK")"
if [ "$OHNE_RUECKFRAGE" != "ja" ]; then
read -r -p "Wirklich zurückspielen? [tippen Sie ja] " antwort
[ "$antwort" = "ja" ] || fehler "Abgebrochen."
fi
blau "Weboberfläche und Worker anhalten …"
docker compose stop web worker
blau "Datenbank zurückspielen …"
# Eine Transaktion: scheitert etwas, bleibt der alte Stand vollstaendig erhalten (in der
# Wiederherstellungsprobe hatte ein Abbruch ohne Transaktion das Schema pgboss halb geloescht).
gunzip -c "$DATENBANK" | docker compose exec -T db psql -U sshkv -d sshkv -v ON_ERROR_STOP=1 --single-transaction -q >/dev/null \
|| fehler "Zurückspielen fehlgeschlagen. Web und Worker sind angehalten – nach Klärung: docker compose up -d"
# Die Warteschlange gehoert nicht zur Sicherung: frisch anlegen lassen (macht der Worker beim
# Start), offene Vorgaenge abbrechen – ihre Jobs sind damit weg.
docker compose exec -T db psql -U sshkv -d sshkv -q -c "DROP SCHEMA IF EXISTS pgboss CASCADE" >/dev/null
docker compose exec -T db psql -U sshkv -d sshkv -q -c \
"UPDATE \"Vorgang\" SET status = 'ABGEBROCHEN', fehler = 'Durch Wiederherstellung abgebrochen.', \"beendetAm\" = now() WHERE status IN ('WARTEND','LAEUFT')" >/dev/null
blau "Dienste starten …"
docker compose up -d --wait web worker
sleep 3
if docker compose logs --tail 20 worker 2>&1 | grep -q "passt nicht zur Datenbank"; then
fehler "Der Worker meldet: Master-Key passt nicht zur Datenbank. Siehe docker compose logs worker."
fi
gruen "Wiederhergestellt aus $(basename "$DATENBANK")."
+22 -1
View File
@@ -64,6 +64,17 @@ services:
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins} SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators} SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers} SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
# Lokale Konten mit Passwort und TOTP (#035); 0 schaltet sie ab, der Notfall-Admin bleibt.
SSHKV_LOKALE_KONTEN: ${SSHKV_LOKALE_KONTEN:-1}
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
# Nur zur Anzeige unter /einstellungen; ausgefuehrt werden die Zeitplaene im Worker.
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
SSHKV_MELDUNG_CRON: ${SSHKV_MELDUNG_CRON:-0 7 * * *}
# Absichtlich KEIN Master-Key: eine Luecke im Webprozess allein entschluesselt keine # Absichtlich KEIN Master-Key: eine Luecke im Webprozess allein entschluesselt keine
# Private Keys. Nach aussen braucht er nur den OIDC-Anbieter. # Private Keys. Nach aussen braucht er nur den OIDC-Anbieter.
ports: ports:
@@ -94,9 +105,19 @@ services:
SSHKV_APP_SECRET: ${SSHKV_APP_SECRET} SSHKV_APP_SECRET: ${SSHKV_APP_SECRET}
SSHKV_MASTER_KEY_FILE: /run/secrets/master_key SSHKV_MASTER_KEY_FILE: /run/secrets/master_key
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *} SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
# Benachrichtigungen (#028): taegliche Zusammenfassung; Links in den Nachrichten zeigen
# auf SSHKV_APP_URL. Den Weg zu Mailserver und Webhook-Ziel hat der Worker ueber "aussen".
SSHKV_MELDUNG_CRON: ${SSHKV_MELDUNG_CRON:-0 7 * * *}
SSHKV_APP_URL: ${SSHKV_APP_URL}
secrets: secrets:
- master_key - master_key
# Der Worker braucht die Datenbank und den Weg zu den Hosts. # Der Worker braucht die Datenbank und den Weg zu den Hosts (und zu Mailserver/Webhook).
networks: [datenbank, aussen] networks: [datenbank, aussen]
healthcheck: healthcheck:
test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"] test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"]
+257
View File
@@ -0,0 +1,257 @@
# SSHKeyVault – Handbuch
SSHKeyVault (`sshkv`) verwaltet SSH-Schlüssel zentral: Schlüsselpaare erzeugen, Public Keys in
`authorized_keys` verteilen, Private Keys gezielt auf andere Hosts legen, Ist und Soll abgleichen –
alles protokolliert. Installation und Betrieb: [../betrieb/installation.md](../betrieb/installation.md),
Sicherung: [../betrieb/sicherung.md](../betrieb/sicherung.md), Sicherheit:
[../betrieb/sicherheit.md](../betrieb/sicherheit.md).
**Grundsatz:** Auf einem Host ändert sich nur etwas, nachdem Sie einen **Dry-Run** gesehen und
„Änderung anwenden“ geklickt haben. Alles, was sshkv in `authorized_keys`, `~/.ssh/config` und
`known_hosts` schreibt, steht in einem verwalteten Block:
```
# BEGIN SSHKV MANAGED – nicht manuell bearbeiten
…
# END SSHKV MANAGED
```
Zeilen außerhalb dieses Blocks bleiben unverändert – außer Sie übernehmen einen Schlüssel ausdrücklich
in die Verwaltung.
1. [Anmelden](#1-anmelden)
2. [Übersicht](#2-übersicht)
3. [Einen Host aufnehmen](#3-einen-host-aufnehmen)
4. [Kern-Anwendungsfall: Host A meldet sich bei Host B an](#4-kern-anwendungsfall-host-a-meldet-sich-bei-host-b-an)
5. [Deployment-Zugang für Claude Code mit dem Assistenten](#5-deployment-zugang-für-claude-code-mit-dem-assistenten)
6. [Abgleich, Drift und Übernahme](#6-abgleich-drift-und-übernahme)
7. [Rotation und Widerruf](#7-rotation-und-widerruf)
8. [Personen, Self-Service und Zuweisungsmatrix](#8-personen-self-service-und-zuweisungsmatrix)
9. [Vorlagen](#9-vorlagen)
10. [Private Key anzeigen oder löschen](#10-private-key-anzeigen-oder-löschen)
11. [Audit-Log](#11-audit-log)
12. [Einstellungen und Benachrichtigungen](#12-einstellungen-und-benachrichtigungen)
---
## 1. Anmelden
![Anmeldeseite](bilder/anmelden-desktop.png)
- **Mit authentik anmelden** – der Normalfall. Ihre Rolle (Leser, Operator, Admin) kommt aus den
authentik-Gruppen und wird bei jeder Anmeldung neu bestimmt.
- **Lokales Konto** – ohne authentik oder zusätzlich dazu. Ein Admin legt das Konto an und gibt
Ihnen ein **Einmal-Passwort**. Bei der ersten Anmeldung (Abschnitt „Lokales Konto oder
Notfallzugang“) setzen Sie ein eigenes Passwort (mindestens 12 Zeichen) und richten den zweiten
Faktor ein: QR-Code mit einer Authenticator-App scannen, Code bestätigen. Danach immer
Anmeldename, Passwort und Code.
- **Notfallzugang** – nur, wenn authentik ausfällt. Gleiches Formular; der Notfall-Admin richtet
bei der ersten Anmeldung ebenfalls den zweiten Faktor ein.
Kritische Aktionen (Private Key anzeigen, Benutzer mit sudo anlegen, Massenwiderruf, Host-Key
ersetzen) verlangen eine **erneute Bestätigung**; sie gilt fünf Minuten.
## 2. Übersicht
![Übersicht](bilder/uebersicht-desktop.png)
Kennzahlen, Hosts mit Abweichung, bald ablaufende Schlüssel und die letzten Aktionen. Von hier aus:
**Jetzt abgleichen** (alle Hosts), **Host-zu-Host-Assistent**, **Neuer Schlüssel**.
## 3. Einen Host aufnehmen
**Hosts → + Host anlegen:** Name (z. B. `srv-pve01`), Adresse, Port, Gruppen, Tags. Bei
**Anmeldung** ist „Dienstbenutzer `sshkv` mit sudo“ empfohlen – im auth.log des Hosts ist dann
eindeutig zu sehen, was von sshkv kam, und root-Login kann aus bleiben.
**Host-Key bestätigen.** „Host-Key abrufen“ holt den Fingerprint, ohne sich anzumelden. Vergleichen
Sie ihn direkt am Host:
```bash
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
```
![Host-Key bestätigen](bilder/hostkey-bestaetigen-desktop.png)
Erst nach „Fingerprint stimmt – vertrauen“ verbindet sich sshkv. Ändert sich der Host-Key später,
bricht jede Verbindung ab und die Hostseite warnt deutlich – annehmen nur, wenn der Host wirklich neu
installiert wurde.
**Bootstrap.** Zwei Wege, den Deployment-Key von sshkv auf den Host zu bringen:
- **Einmalig per Passwort** (root oder ein Benutzer mit sudo). Das Passwort wird nicht gespeichert.
- **Per Befehl:** den angezeigten Befehl auf dem Host einfügen, danach „Einrichtung prüfen“.
![Eingerichteter Host](bilder/host-eingerichtet-desktop.png)
Danach zeigt die Hostseite den **sshd-Befund**. Steht dort „root darf sich per Passwort anmelden“,
empfiehlt sich `PermitRootLogin prohibit-password` in `/etc/ssh/sshd_config` – sshkv ändert das nicht
von sich aus.
## 4. Kern-Anwendungsfall: Host A meldet sich bei Host B an
Beispiel: Der Backup-Server `srv-backup01` soll sich als `root` per SSH auf `srv-pve01` anmelden.
Beide Hosts sind aufgenommen (Kapitel 3).
**1. Schlüssel erzeugen.** *Schlüssel → + Schlüssel anlegen → Erzeugen*: Zweck „Backup srv-backup01 →
srv-pve01“, Typ ed25519, optional Ablaufdatum. Keine Passphrase – ein Cronjob kann sie nicht eingeben.
![Schlüssel anlegen](bilder/schluessel-neu-desktop.png)
**2a. Private Key auf A.** In der Karte *Private Key ausgerollt auf* → **+ Ausrollen**: Quell-Host
`srv-backup01`, Benutzer `root`, Pfad `~/.ssh/id_ed25519_…`. Mit **Eintrag in ~/.ssh/config** und
Ziel-Host `srv-pve01` legt sshkv einen Alias an und trägt den (bereits bestätigten) Host-Key von B in
`known_hosts` von A ein. *HostName* ist die Adresse, unter der **A** den Host B erreicht.
**2b. Public Key auf B.** In der Karte *Public Key ausgerollt auf* → **+ Ausrollen**: Ziel
`srv-pve01`, Benutzer `root`, optional Einschränkungen wie `from="10.0.0.12",no-pty`.
2a und 2b sind unabhängig und jederzeit nachholbar.
**3. Dry-Run und anwenden.** *Dry-Run starten* zeigt je Host, was sich ändert – beim Private Key nur
„wird angelegt“, nie den Inhalt.
![Dry-Run](bilder/kern-dry-run-desktop.png)
„Änderung anwenden“ schreibt genau das, was Sie gesehen haben (mit Sicherung der Dateien). Hat sich
in der Zwischenzeit etwas auf dem Host geändert, bricht sshkv ab – dann den Dry-Run neu starten.
**4. Verbindung testen.** In der Private-Karte „Verbindung testen“ ruft auf A
`ssh -o BatchMode=yes <alias> true` auf – genau das, was später der Cronjob braucht.
![Ausgerollt und getestet](bilder/kern-ausgerollt-desktop.png)
## 5. Deployment-Zugang für Claude Code mit dem Assistenten
Szenario: Claude Code auf `srv-rz-claude-linux` soll eine Anwendung per SSH auf `srv-app01`
ausrollen. **Übersicht → Host-zu-Host-Assistent** (nur Admins):
1. **Vorlage „Deployment-Zugang“** wählen, Zweck „Claude Code Deployment → srv-app01“. Die Vorlage
setzt Benutzer `deploy`, 90 Tage Laufzeit, `from="<IP von A>"` und den Alias `deploy-<Ziel>`.
2. **Quelle:** `srv-rz-claude-linux`, Benutzer, unter dem Claude Code läuft.
3. **Ziel:** `srv-app01`, Benutzer `deploy` – „Benutzer anlegen“ ist vorgewählt; sudo nach Bedarf
(keine, bestimmte Befehle mit Vorschau der sudoers-Datei, oder voll). Fingerprint von B bestätigen.
![Assistent – Ziel](bilder/assistent-ziel-desktop.png)
4. **Prüfen** und „Vorbereiten“: sshkv erzeugt den Schlüssel, legt `deploy` an (frische Bestätigung
nötig – sie öffnet sich in einem neuen Tab, Ihre Eingaben bleiben stehen) und legt die
Zuweisungen an.
5. **Ausrollen:** Dry-Run, anwenden, „Verbindung von A nach B testen“.
![Assistent – fertig](bilder/assistent-fertig-desktop.png)
Claude Code kann danach einfach `ssh deploy-srv-app01` benutzen. Wählen Sie `root` als Ziel, warnt
der Assistent: Ein KI-Agent hätte dann uneingeschränkten Zugriff.
**Nach Projektende** (oder Ablaufdatum): auf der Schlüsselseite **Widerrufen** – entfernt Public und
Private Key, config- und known_hosts-Eintrag und auf Wunsch den Benutzer `deploy` samt sudoers-Datei.
## 6. Abgleich, Drift und Übernahme
Nachts um 02:30 (einstellbar) und auf Knopfdruck vergleicht sshkv jeden Host mit dem Soll. Gefunden
werden fehlende Schlüssel, von Hand geänderte Blöcke, **unbekannte Schlüssel** außerhalb des Blocks,
falsche Rechte von `~/.ssh` und veränderte Private Keys. Der Abgleich ändert auf dem Host nichts.
![Abgleich](bilder/abgleich-desktop.png)
Ein unbekannter Schlüssel lässt sich **übernehmen**: nur als bekannt aufnehmen (der Befund
verschwindet) oder „in den verwalteten Block holen“ – dann wandert die Zeile beim nächsten Ausrollen
(mit Dry-Run) in den Block.
## 7. Rotation und Widerruf
**Rotieren** (Schlüsselseite): Ein neuer Schlüssel kommt an alle Stellen des alten, der Alias in
`~/.ssh/config` zeigt auf die neue Datei, die Verbindungen werden getestet – **erst dann** wird der
alte entfernt. Die Rotation lässt sich jederzeit fortsetzen.
![Rotation](bilder/rotation-desktop.png)
**Widerrufen** (Schlüsselseite, Gefahr-Knopf): Der Dialog zeigt, was entfernt wird; danach Dry-Run
und anwenden. Ist ein Host nicht erreichbar, bleibt der Schlüssel als „Widerruf unvollständig“
markiert, bis auch dort alles entfernt ist. **Massenwiderruf:** in der Schlüsselliste mehrere
auswählen (nur Admin).
![Widerruf](bilder/widerruf-desktop.png)
**Deployment-Key rotieren** (Einstellungen → Instanz, nur Admin): fünf Schritte – neuen Key
**erzeugen**, per Dry-Run auf alle Hosts **verteilen** (er steht dann neben dem alten), die Anmeldung
mit dem neuen Key **prüfen**, **umstellen** und zuletzt den alten per Dry-Run **aufräumen**. Scheitert
die Prüfung auf einem Host, lässt er sich bewusst übergehen; sshkv erreicht ihn danach nur noch nach
einem neuen Bootstrap. Solange noch nicht umgestellt ist, lässt sich die
Rotation **abbrechen**: Der neue Key wird verworfen, ein Dry-Run nimmt ihn wieder von den Hosts.
![Deployment-Key rotieren](bilder/deployment-key-desktop.png)
## 8. Personen, Self-Service und Zuweisungsmatrix
- **Personen:** Admins, Mitarbeiter, Dienstleister mit ihren Schlüsseln. Private Keys von Personen
speichert sshkv nie.
- **Meine Schlüssel:** Jede angemeldete Person kann eigene Public Keys hinterlegen. Wirksam werden
sie erst, wenn ein Operator oder Admin sie einem Host zuweist.
![Meine Schlüssel](bilder/meine-schluessel-desktop.png)
- **Zuweisungen:** Matrix Schlüssel × Host mit Filtern; ein Klick auf eine Zelle zeigt die
Zuweisungen und erlaubt neue. Export als CSV.
![Zuweisungen](bilder/zuweisungen-desktop.png)
## 9. Vorlagen
Vorlagen legen Schlüsseltyp, Laufzeit, Zielbenutzer (samt Anlegen und sudo), `authorized_keys`-
Optionen und das Alias-Muster vor. Platzhalter: `{{quellHost}}`, `{{quellHostIp}}`, `{{zielHost}}`,
`{{zielHostIp}}`. Eingebaut: „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“.
![Vorlage bearbeiten](bilder/vorlage-bearbeiten-desktop.png)
## 10. Private Key anzeigen oder löschen
Nur Admins, nur nach erneuter Bestätigung: „anzeigen (Re-Auth)“ in der Info-Leiste eines Schlüssels.
Der Key wird genau einmal ausgeliefert und nirgends zwischengespeichert; jede Anzeige steht im
Protokoll. **Private Key löschen** entfernt ihn endgültig aus sshkv – auf den Hosts bleibt er; neu
ausrollen geht danach nicht mehr.
## 11. Audit-Log
Jede Aktion steht im **Audit-Log**: Zeitpunkt, Benutzer, Host, Zielbenutzer, Fingerprint, das Diff
der geänderten Datei und das Ergebnis – nie ein Private Key oder Passwort. Einträge lassen sich
weder ändern noch löschen; jeder Eintrag ist per Hash mit seinem Vorgänger verkettet.
![Audit-Log](bilder/protokoll-desktop.png)
- **Filter** nach Zeitraum, Aktionsgruppe, Benutzer, Host, Ergebnis und Freitext; eine Zeile
aufklappen zeigt Diff, Details und Hashes.
- **Kette prüfen** rechnet alle Hashes nach. Wurde eine Zeile an sshkv vorbei in der Datenbank
verändert, zeigt die Seite die erste betroffene Zeile.
- **CSV-Export** mit denselben Filtern (der Export selbst wird protokolliert).
- **Syslog:** Mit `SSHKV_SYSLOG_HOST` geht jeder Eintrag zusätzlich an einen zentralen Syslog-Server
(z. B. für Zabbix) – eine Kopie außerhalb von sshkv.
## 12. Einstellungen und Benachrichtigungen
**Benutzer** (Einstellungen → Instanz → „Konten verwalten“, nur Admin): lokale Konten anlegen –
sshkv erzeugt das Einmal-Passwort und zeigt es **genau einmal** –, Rollen ändern, Konten sperren
(beendet laufende Sitzungen sofort) und zurücksetzen (neues Einmal-Passwort, TOTP neu einrichten,
etwa nach verlorenem Telefon). Konten aus authentik erscheinen nach ihrer ersten Anmeldung; ihre
Rolle kommt aus den Gruppen, sie lassen sich hier nur sperren. Der Notfall-Admin ist
unveränderlich.
![Benutzer](bilder/benutzer-desktop.png)
Unter **Einstellungen** (ändern nur Admins) richten Sie Benachrichtigungen ein:
- **E-Mail** über SMTP – für Microsoft 365 vorbelegt (`smtp.office365.com:587`, STARTTLS).
- **Webhook** (nur `https://`, außer ausdrücklich für interne Ziele erlaubt) mit optionaler Signatur
`X-Sshkv-Signatur: sha256=…` über den Inhalt.
- **Anlässe** einzeln schaltbar: neue Abweichungen, Schlüssel, die in 30/14/7 Tagen ablaufen,
abgelaufene, aber noch ausgerollte Schlüssel, nicht erreichbare Hosts – als **tägliche
Zusammenfassung** um 07:00 (`SSHKV_MELDUNG_CRON`) – und **geänderte Host-Keys sofort**.
- **Testnachricht senden** je Kanal; die letzten Zustellungen stehen darunter. Jede Meldung geht nur
einmal hinaus; fehlgeschlagene werden einige Male wiederholt.
Passwörter und Geheimnisse werden verschlüsselt gespeichert und nie wieder angezeigt („gesetzt“).
![Einstellungen](bilder/einstellungen-desktop.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+143
View File
@@ -0,0 +1,143 @@
# Abnahme vor Version 1.0.0
Was die automatischen Tests **nicht** abdecken können: der echte Anbieter (authentik), echte Hosts
und der Betrieb auf dem Zielserver. Diese Liste geht genau das durch. Jeder Punkt nennt das
erwartete Ergebnis; was abweicht, kommt als Issue in `docs/issues/` (oder direkt als Notiz hier
darunter).
Voraussetzung: Installation nach [installation.md](installation.md) ist durch, `docker compose ps`
zeigt `db`, `web` und `worker` als *healthy*.
**Abnahme durch:** ____________________ **Datum:** __________ **Version/Commit:** __________
---
## A · Installation und Grundschutz
- [ ] `https://<sshkv>/api/gesundheit` antwortet mit `{"status":"ok","version":…}`; über `http://` gibt es keine Anmeldung
(Secure-Cookie).
- [ ] Von außerhalb des internen Netzes ist die Oberfläche **nicht** erreichbar (Reverse Proxy /
Firewall liefert 403 oder Timeout).
- [ ] Der Web-Container sieht den Master-Key nicht:
`docker compose exec web ls /run/secrets` → leer bzw. Fehler.
- [ ] Worker-Log beim Start: Master-Key passt, Deployment-Key vorhanden
(`docker compose logs worker | head`).
- [ ] Antwort-Header prüfen (`curl -sI https://<sshkv>/anmelden`): `Content-Security-Policy`,
`Strict-Transport-Security`, `X-Frame-Options`/`frame-ancestors`, `Referrer-Policy`.
## B · Anmeldung mit authentik
- [ ] Anmelden mit einem Konto in `sshkv-admins` → Rolle **Admin** (unten links in der Navigation).
- [ ] Konto in `sshkv-operators` → **Operator**; Konto in `sshkv-readers` → **Leser**.
- [ ] Konto in **keiner** dieser Gruppen → kein Zugang, verständliche Meldung, Eintrag
`anmeldung.verweigert` im Audit-Log.
- [ ] Gruppe in authentik entziehen → bei der nächsten Anfrage gilt die neue Rolle bzw. kein
Zugang (die Rolle kommt aus der Datenbank, nicht aus dem alten Token – spätestens nach
erneuter Anmeldung).
- [ ] **Erneute Bestätigung:** als Admin „Private Key anzeigen“ wählen → authentik fragt das
Passwort (und ggf. MFA) **wirklich neu** ab; danach geht die Aktion fünf Minuten lang ohne
erneute Frage. *Kritischer Punkt:* Kommt authentik ohne neue Eingabe zurück, meldet sshkv
„auth_time fehlt oder ist zu alt“ – dann die Flow-Einstellung in authentik prüfen.
- [ ] Abmelden beendet die Sitzung (Zurück-Knopf zeigt keine Daten mehr).
## B2 · Lokale Konten (wenn ohne oder neben authentik)
- [ ] Einstellungen → „Konten verwalten“ → Konto anlegen (frische Bestätigung nötig) → das
Einmal-Passwort erscheint genau einmal; nach Neuladen ist es weg.
- [ ] Erste Anmeldung im privaten Browserfenster: eigenes Passwort setzen (unter 12 Zeichen wird
abgelehnt), TOTP einrichten, Rolle stimmt (unten links „über lokales Konto“).
- [ ] Sperren → die Sitzung im anderen Fenster endet beim nächsten Klick; Entsperren → wieder
anmeldbar.
- [ ] Zurücksetzen → neues Einmal-Passwort, TOTP wird neu eingerichtet.
- [ ] Audit-Log enthält Anlegen, Passwort gesetzt, Sperren – **kein** Passwort im Klartext.
- [ ] Mit `SSHKV_LOKALE_KONTEN=0` und `docker compose up -d`: lokales Konto kommt nicht mehr
hinein, der Notfall-Admin schon.
## C · Notfall-Admin
- [ ] `/anmelden` → „Notfallzugang“: Benutzer `notfall` + Passwort aus `.env` → TOTP-Einrichtung
erzwungen (QR-Code mit Authenticator-App scannen).
- [ ] Erneute Anmeldung verlangt Passwort **und** TOTP-Code; falscher Code wird abgelehnt.
- [ ] Nach fünf Fehlversuchen in 15 Minuten → „Zu viele Fehlversuche“, auch mit richtigem Passwort.
- [ ] `SSHKV_BREAKGLASS_PASSWORD` danach aus der `.env` entfernen (der Hash steht in der
Datenbank) und das Passwort im Passwortmanager ablegen.
## D · Echte Hosts aufnehmen
Mindestens zwei Hosts, möglichst unterschiedlich (z. B. Debian 12 und Ubuntu 24.04), einer mit
Anmeldeart *Dienstbenutzer*, einer mit *root*.
- [ ] Host anlegen → „Host-Key abrufen“ → Fingerprint mit dem Host vergleichen
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` **auf dem Host**) → vertrauen.
- [ ] Bootstrap per Einmal-Passwort: danach existiert `sshkv` mit `/etc/sudoers.d/sshkv`, das
Passwort steht **nirgends** (Audit-Log, `docker compose logs`, Datenbank:
`grep -r` im Dump findet es nicht).
- [ ] Bootstrap per Kopier-Befehl auf dem zweiten Host: Befehl in die Root-Shell einfügen, danach
„Einrichtung erneut prüfen“ → eingerichtet.
- [ ] „Verbindung prüfen“ → erreichbar; sshd-Befund wird angezeigt (z. B. PasswordAuthentication).
- [ ] Host-Key auf einem Host absichtlich tauschen (`ssh-keygen -A` nach Löschen der Keys,
sshd neu starten) → nächster Abgleich meldet **Host-Key geändert**, sshkv verbindet sich
nicht mehr, bis ein Admin den neuen Key bestätigt. Danach zurückdrehen.
## E · Schlüssel ausrollen (Kern-Anwendungsfall)
- [ ] Vorhandene `authorized_keys` eines Hosts mit eigenen Zeilen (Kommentare, Optionen, leere
Zeilen) vorher sichern: `cp ~/.ssh/authorized_keys /tmp/vorher`.
- [ ] Public Key eines Admins auf beide Hosts ausrollen → Dry-Run zeigt **nur** den verwalteten
Block; nach „Anwenden“ ist alles außerhalb des Blocks **byte-gleich**
(`diff` gegen `/tmp/vorher` zeigt nur den Block).
- [ ] Backup-Datei `authorized_keys.sshkv.bak.*` liegt daneben; Rechte 0600, Verzeichnis 0700.
- [ ] Host-zu-Host-Assistent: Vorlage „Deployment-Zugang“ von Host A nach Host B → Verbindungstest
grün; auf A: `ssh <alias> id -un` funktioniert ohne Rückfrage (known_hosts gesetzt).
- [ ] Datei `~/.ssh/config` auf A: eigener Inhalt außerhalb des sshkv-Blocks unverändert.
- [ ] Zielkonto anlegen (z. B. `deploy` mit sudo) → Benutzer und sudoers-Datei entstehen,
`visudo -c` auf dem Host ist sauber.
## F · Drift, Rotation, Widerruf
- [ ] Im verwalteten Block auf einem Host von Hand eine Zeile ändern → Abgleich meldet
Abweichung; Übersicht zeigt den Host; Dry-Run stellt den Soll-Stand wieder her.
- [ ] Fremden Key außerhalb des Blocks eintragen → Abgleich meldet **unbekannten Schlüssel**;
„übernehmen“ funktioniert.
- [ ] Geführte Rotation des Assistenten-Schlüssels: Alias auf A funktioniert vorher, während und
nachher; alte Datei ist weg.
- [ ] Rotation einmal **abbrechen** und danach erneut rotieren (#032, #033).
- [ ] Widerruf eines Schlüssels mit einem nicht erreichbaren Host (Host kurz stoppen) →
„Widerruf unvollständig“; nach dem Wiederanlauf abschließbar.
- [ ] **Deployment-Key rotieren** (Einstellungen → Instanz) über alle echten Hosts – danach
klappen Abgleich und Ausrollen weiter; der alte Fingerprint steht auf keinem Host mehr.
- [ ] Aussperrschutz: Den Deployment-Key auf einem Host per Hand aus dem Block löschen und
ausrollen → sshkv schreibt ihn zurück bzw. verweigert eine Änderung, die ihn entfernen würde.
## G · Audit-Log und Benachrichtigungen
- [ ] Audit-Log zeigt alle Aktionen aus D–F mit Akteur; „Kette prüfen“ → intakt.
- [ ] CSV-Export öffnet sich sauber in LibreOffice/Excel (Umlaute, Trennzeichen).
- [ ] Syslog-Kopie kommt beim Zielserver an (falls `SSHKV_SYSLOG_*` gesetzt).
- [ ] Testversand E-Mail (Microsoft 365) und Webhook aus den Einstellungen kommt an.
- [ ] Am nächsten Morgen: tägliche Zusammenfassung ist da und nennt die offenen Befunde.
## H · Sicherung und Wiederherstellung
- [ ] `sudo bash deploy/sichern.sh` läuft durch; Sicherung landet am vorgesehenen Ort.
- [ ] Master-Key **getrennt** gesichert (Passwortmanager/Tresor), Wiederauffinden geprobt.
- [ ] Wiederherstellung auf einer **Wegwerf-Instanz** nach [sicherung.md](sicherung.md): danach
Anmeldung, Private Key anzeigen und ein Abgleich funktionieren.
- [ ] Mit falschem Master-Key verweigert der Worker den Start.
## I · Oberfläche
- [ ] Smartphone (schmale Ansicht): Navigation, Übersicht, Hostliste und Dry-Run bedienbar.
- [ ] Hell/dunkel: Die App ist bewusst dunkel (Tresor-Design); Kontrast in heller Umgebung
ausreichend lesbar.
- [ ] Handbuch (`docs/anleitung/README.md`) passt zu dem, was die Oberfläche zeigt.
---
## Ergebnis
- [ ] Alle Punkte erledigt oder als Issue erfasst und als *nicht blockierend* eingestuft.
- [ ] **Freigabe für 1.0.0** – danach: Version in `package.json`, CHANGELOG-Abschnitt `[1.0.0]`,
Tag `v1.0.0`, Push.
### Notizen
+127
View File
@@ -0,0 +1,127 @@
# Installation und Betrieb
SSHKeyVault läuft als Docker-Compose-Stapel aus drei Diensten plus einem einmaligen Migrationslauf:
| Dienst | Aufgabe | Netz | Geheimnisse |
|---|---|---|---|
| `db` | PostgreSQL 17 | nur intern (`internal: true`) | Datenbankpasswort |
| `migrate` | Schema einspielen, Notfall-Admin und Vorlagen anlegen (läuft bei jedem Start einmal) | intern | Notfall-Passwort beim ersten Start |
| `web` | Weboberfläche (Port 3400) | intern + nach außen zum OIDC-Anbieter | `SSHKV_APP_SECRET`, OIDC-Client-Secret – **kein Master-Key** |
| `worker` | SSH zu den Hosts, Ver-/Entschlüsselung, Zeitpläne | intern + zu den Hosts | Master-Key (Docker-Secret), `SSHKV_APP_SECRET` |
## Voraussetzungen
- Linux-Host mit Docker und Docker Compose v2, **im internen Netz bzw. nur per VPN erreichbar**.
- Der Worker muss die zu verwaltenden Hosts per SSH erreichen (Port 22 oder den jeweiligen Port).
- authentik (oder ein anderer OIDC-Anbieter) für die Anmeldung.
- Ein Reverse Proxy mit TLS vor der Weboberfläche.
## Schritt für Schritt
```bash
sudo mkdir -p /opt/sshkv && cd /opt/sshkv
git clone <repo> . # oder Release entpacken
cp .env.example .env && chmod 600 .env
```
In `.env` eintragen (Kommentare in der Datei erklären jede Variable):
- `POSTGRES_PASSWORD` – `openssl rand -hex 24`
- `SSHKV_APP_SECRET` – `openssl rand -base64 48`
- `SSHKV_APP_URL` – die Adresse, unter der Benutzer sshkv aufrufen, z. B. `https://sshkv.intern.example.de`
- `SSHKV_BREAKGLASS_PASSWORD` – mindestens 16 Zeichen; Notfall-Admin `notfall`
- `SSHKV_OIDC_*` – siehe unten
Master-Key erzeugen und nur für den Worker lesbar machen:
```bash
sudo mkdir -p geheim
sudo sh -c 'head -c 32 /dev/urandom > geheim/master.key'
sudo chown 1001:1001 geheim/master.key && sudo chmod 400 geheim/master.key
```
Starten:
```bash
docker compose up -d --build
docker compose logs -f worker # „sshkv-worker … bereit“
```
Beim ersten Start legt `migrate` das Schema, den Notfall-Admin und die eingebauten Vorlagen an; der
Worker erzeugt den **Deployment-Key** der Instanz. Danach sofort den Master-Key sichern
([sicherung.md](sicherung.md)).
## authentik einrichten
1. **Provider** „OAuth2/OpenID Provider“ anlegen: Client-Typ *confidential*, Redirect-URI
`https://sshkv.intern.example.de/api/auth/oidc/rueckruf`, Scopes `openid profile email` und ein
Scope-Mapping, das die Gruppen als Claim `groups` liefert (in authentik Standard).
2. **Application** „SSHKeyVault“ mit diesem Provider anlegen.
3. **Gruppen** `sshkv-admins`, `sshkv-operators`, `sshkv-readers` anlegen und Benutzer zuordnen
(Namen änderbar über `SSHKV_OIDC_GROUP_*`). Wer in keiner Gruppe ist, kommt nicht hinein.
4. In `.env`: `SSHKV_OIDC_ISSUER=https://authentik.example.de/application/o/sshkv/`,
`SSHKV_OIDC_CLIENT_ID`, `SSHKV_OIDC_CLIENT_SECRET`, `SSHKV_OIDC_NAME=authentik`.
Erneute Bestätigung vor kritischen Aktionen schickt `prompt=login` und `max_age=0`; sshkv prüft
`auth_time` im ID-Token. authentik liefert `auth_time` standardmäßig.
| Rolle | darf |
|---|---|
| Leser | alles ansehen, eigene Public Keys hinterlegen |
| Operator | zusätzlich Hosts verwalten, Host-Keys bestätigen, Schlüssel erzeugen, Public Keys ausrollen, abgleichen |
| Admin | zusätzlich Private Keys ausrollen/anzeigen/löschen, Zielkonten mit sudo, Massenwiderruf, Einstellungen, Vorlagen |
## Ohne authentik starten: lokale Konten
authentik ist nicht Pflicht. Ohne `SSHKV_OIDC_*` zeigt die Anmeldeseite nur das Formular für
lokale Konten und den Notfallzugang:
1. Als **Notfall-Admin** anmelden (TOTP einrichten).
2. Einstellungen → „Konten verwalten“ → für jede Person ein Konto mit passender Rolle anlegen und
das Einmal-Passwort auf sicherem Weg weitergeben.
3. Für den Alltag die eigenen Konten nutzen, den Notfall-Admin nur im Notfall – so bleibt im
Audit-Log erkennbar, wer was getan hat.
Später lässt sich authentik dazuschalten; lokale Konten und OIDC laufen nebeneinander.
`SSHKV_LOKALE_KONTEN=0` schaltet die lokalen Konten danach ab.
## Reverse Proxy (Beispiel Caddy)
```caddyfile
sshkv.intern.example.de {
tls internal # oder das interne Zertifikat
@extern not remote_ip 10.0.0.0/8 192.168.0.0/16
respond @extern 403
reverse_proxy 127.0.0.1:3400
}
```
Wichtig: `SSHKV_APP_URL` muss die Adresse mit `https://` sein – davon hängen Secure-Cookie und
OIDC-Rückruf ab.
## Hosts vorbereiten
- **Debian/Ubuntu** mit `openssh-server`, `sudo`, `runuser` (util-linux), `base64`, `sha256sum`,
`visudo` (bei Dienstbenutzer) – auf Standardinstallationen vorhanden.
- Für den Bootstrap per Passwort einmalig ein Konto mit Passwort-Login (root oder ein Benutzer mit
sudo). Danach kann Passwort-Login aus; sshkv zeigt den sshd-Befund an.
## Aktualisieren
```bash
cd /opt/sshkv
sudo bash deploy/sichern.sh
git pull
docker compose up -d --build # migrate spielt neue Migrationen ein
```
## Variablen (Auswahl)
| Variable | Bedeutung |
|---|---|
| `SSHKV_SYNC_CRON` | Zeitplan des Abgleichs (Standard `30 2 * * *`, Europe/Berlin) |
| `SSHKV_MELDUNG_CRON` | tägliche Zusammenfassung der Benachrichtigungen (Standard `0 7 * * *`); geänderte Host-Keys gehen alle 5 Minuten sofort hinaus |
| `SSHKV_SYSLOG_HOST`/`_PORT`/`_PROTOKOLL` | Kopie des Protokolls an einen Syslog-Server (RFC 5424, UDP oder TCP) |
| `SSHKV_SYSLOG_FACILITY` / `_APPNAME` | Facility (Standard `authpriv`) und App-Name (Standard `sshkv`) |
| `SSHKV_WORKER_USER` | Benutzer des Worker-Containers, falls die Master-Key-Datei nicht UID 1001 gehört |
| `SSHKV_BIND` / `SSHKV_PORT` | Bindeadresse/Port der Weboberfläche (Standard `127.0.0.1:3400`) |
+92
View File
@@ -0,0 +1,92 @@
# Sicherheit und Bedrohungsanalyse
SSHKeyVault (`sshkv`) verwaltet die SSH-Zugänge zu Servern – und hat dafür selbst
**root-gleichen Zugriff auf alle verwalteten Hosts**. Wer sshkv übernimmt, übernimmt die
Infrastruktur. Diese Seite beschreibt, wie sshkv aufgebaut ist, um das zu erschweren, was
trotzdem passieren kann und was im Ernstfall zu tun ist.
## Was sshkv besitzt
| Wert | Wo | Schutz |
|---|---|---|
| **Deployment-Key** (Private Key) | Datenbank, verschlüsselt | Envelope Encryption mit Master-Key; nur der Worker entschlüsselt |
| **Private Keys** von Service-Schlüsseln | Datenbank, verschlüsselt | wie oben; Anzeige nur für Admins mit frischer Bestätigung, protokolliert |
| **Master-Key** | Datei auf dem Docker-Host, nur im Worker eingebunden | Dateirechte 0400, nicht in der Datenbank, nicht im Image, nicht im Repo |
| `SSHKV_APP_SECRET` | `.env` | signiert Sitzungen, verschlüsselt TOTP-Geheimnis, SMTP-Passwort, Webhook-Geheimnis und Job-Transporte |
| Host-Keys der Hosts | Datenbank | öffentlich; schützen vor Man-in-the-Middle (strikte Prüfung) |
| Protokoll | Datenbank | append-only per Trigger, Hash-Kette, optional Syslog |
**Personen-Keys** speichert sshkv nur als Public Key – ein Datenbankregel verhindert, dass dort je ein
Private Key steht. Bootstrap-Passwörter werden nie gespeichert.
## Aufbau, der Angriffe erschwert
- **Web und Worker getrennt.** Der Webprozess kennt den Master-Key nicht und spricht kein SSH. Eine
Lücke in der Weboberfläche allein entschlüsselt keine Private Keys; ein Test hält strukturell fest,
dass der Webprozess nichts aus `src/worker` importiert und nur eine einzige Datei die
verschlüsselten Felder liest.
- **Envelope Encryption** (AES-256-GCM): je Private Key ein eigener Datenschlüssel, gebunden an ID und
Fingerprint des Schlüssels. Ein Datenbank-Dump allein gibt keinen Schlüssel preis (Test).
- **Strikte Host-Key-Prüfung** bei jeder Verbindung. Ein geänderter Host-Key bricht die Verbindung vor
dem Senden von Zugangsdaten ab; Ersetzen nur mit Häkchen und frischer Bestätigung.
- **Dateien auf den Hosts** schreibt sshkv als jeweiliger Zielbenutzer (`runuser`): Ein Symlink im
Home eines Benutzers kann root nicht dazu bringen, `/etc/shadow` zu lesen oder zu überschreiben
(Integrationstest).
- **Dry-Run mit optimistischer Sperre:** Ausgerollt wird nur, was ein Mensch gesehen hat. Hat sich die
Datei oder die Zuweisung seitdem geändert, bricht der Vorgang ab.
- **Rollen:** Private Keys anzeigen, ausrollen und löschen, Deployment-Key, Zielkonten mit sudo und
Massenwiderruf nur für Admins; kritische Aktionen zusätzlich mit erneuter Anmeldung (5 Minuten).
Ohne passende authentik-Gruppe gibt es keinen Zugang, auch keinen lesenden.
- **Notfall-Admin und lokale Konten** nur mit Passwort **und** TOTP; Rate-Limit gegen Raten
(5 Fehlversuche je Anmeldename in 15 Minuten). Lokale Konten bekommen ein vom System erzeugtes
Einmal-Passwort, das bei der ersten Anmeldung ersetzt werden muss – Admins kennen die Passwörter
anderer nie. Sperren beendet Sitzungen sofort; `SSHKV_LOKALE_KONTEN=0` schaltet lokale Konten
ganz ab.
- **Weboberfläche:** Content-Security-Policy mit Nonce, `X-Frame-Options: DENY`, `Referrer-Policy:
no-referrer`, HttpOnly-/SameSite-Cookies, Secure hinter HTTPS; Server Actions mit Origin-Prüfung.
## Was passiert, wenn sshkv kompromittiert wird?
| Angreifer hat … | Folge | Gegenmaßnahme |
|---|---|---|
| nur ein **Datenbank-Backup** | Hosts, Zuweisungen, Public Keys, Protokoll sichtbar; **keine** Private Keys | Backup trotzdem vertraulich behandeln (verrät die Struktur der Infrastruktur) |
| **Datenbank + Master-Key** | alle Private Keys inkl. Deployment-Key → root auf allen Hosts | Master-Key getrennt aufbewahren; Notfallplan unten |
| die **Weboberfläche** als Admin (gestohlene Sitzung) | kann Schlüssel zuweisen und ausrollen; Private Keys nur nach erneuter Anmeldung | kurze Sitzungen (8 h), Re-Auth, Protokoll + Syslog zeigen jede Aktion |
| den **Webprozess** (Codeausführung) | Datenbankzugriff, aber kein Master-Key | Worker getrennt; Master-Key nur im Worker-Container |
| den **Worker** oder Docker-Host | alles | Host härten, nur internes Netz, Updates, Zugriff auf den Docker-Host streng begrenzen |
| einen **verwalteten Host** | kann sshkv nicht angreifen; sieht nur den Public Key des Deployment-Keys | — |
### Notfallplan „sshkv kompromittiert“
1. **Sofort:** Netzwerkzugang zu sshkv sperren (Firewall/VPN), Container anhalten
(`docker compose stop`). Die Hosts bleiben erreichbar – sshkv ist kein Teil des Logins.
2. **Deployment-Key auf allen Hosts entfernen:** Auf jedem Host in `~sshkv/.ssh/authorized_keys`
(bzw. `/root/.ssh/authorized_keys` bei Anmeldeart root) die Zeile mit `sshkv-deployment@…` löschen
und `/etc/sudoers.d/sshkv` entfernen. Mit einem Konfigurationsmanagement oder einer Schleife über
die Hostliste (CSV-Export der Zuweisungen bzw. Hostliste).
3. **Ausgerollte Service-Keys tauschen:** Jeder Private Key, den sshkv kannte, gilt als bekannt.
Nach dem Wiederaufbau alle Service-Schlüssel rotieren (geführte Rotation) und alte widerrufen.
4. **Neu aufsetzen:** neuer Master-Key, neues `SSHKV_APP_SECRET`, frische Datenbank oder geprüftes
Backup; der Worker erzeugt einen neuen Deployment-Key; Hosts per Bootstrap neu aufnehmen.
5. **Protokoll auswerten:** Syslog-Kopie (unveränderlich außerhalb von sshkv) mit dem Protokoll
vergleichen; `protokoll_kette_pruefen()` zeigt manipulierte Zeilen.
> **Rotation des Deployment-Keys** (Einstellungen → Instanz → „Deployment-Key rotieren“, nur Admin mit
> frischer Bestätigung): regelmäßig, oder wenn nur der Key abgeflossen sein kann – etwa ein verlorenes
> Backup samt Master-Key. Der neue Key kommt erst neben den alten, wird auf jedem Host geprüft und löst
> den alten dann ab; zuletzt wird der alte aus allen Blöcken entfernt und sein Private Key verworfen.
> Ist sshkv **selbst** kompromittiert, hilft das nicht – der Instanz ist dann nicht mehr zu trauen,
> Schritt 2 bleibt Handarbeit.
## Betriebsregeln
- **Nur intern betreiben.** Die Weboberfläche gehört ins interne Netz oder hinter ein VPN, nie ins
Internet. Compose bindet standardmäßig nur an `127.0.0.1`; ein Reverse Proxy mit TLS steht davor
(siehe [installation.md](installation.md)).
- **Docker-Host wie einen Bastion-Host behandeln:** wenige Admins, SSH nur mit Schlüssel, Updates,
keine anderen Dienste.
- **Master-Key** getrennt sichern, Rechte 0400, nur für den Worker-Benutzer lesbar.
- **Syslog** einschalten (`SSHKV_SYSLOG_*`), damit eine Kopie des Protokolls außerhalb von sshkv liegt.
- **Hosts:** `PermitRootLogin prohibit-password` (sshkv zeigt das als Hinweis an), Anmeldeart
„Dienstbenutzer“ bevorzugen – dann kann root-Login ganz aus bleiben.
- **Benachrichtigungen** für Drift und geänderte Host-Keys aktivieren, damit Abweichungen auffallen.
+92
View File
@@ -0,0 +1,92 @@
# Sicherung und Wiederherstellung
Eine Sicherung von SSHKeyVault besteht aus **zwei Teilen, die getrennt aufbewahrt werden**:
| Teil | Inhalt | Wie oft | Wohin |
|---|---|---|---|
| **Datenbank** | Hosts, Schlüssel (Private Keys nur verschlüsselt), Zuweisungen, Protokoll, Einstellungen | täglich | normales Backup-Ziel (`/var/backups/sshkv`, von dort auf das Sicherungssystem) |
| **Master-Key** | 32 Byte, entschlüsselt alle Private Keys | einmalig nach der Installation und nach jeder Master-Key-Rotation | **getrennt:** Passwortmanager, Tresor, verschlüsselter Datenträger |
| `.env` | `SSHKV_APP_SECRET`, OIDC-Zugang, Datenbankpasswort | nach jeder Änderung | wie der Master-Key |
> **Ohne Master-Key ist jedes Datenbank-Backup wertlos** – die Private Keys lassen sich nicht mehr
> entschlüsseln. **Mit Master-Key neben dem Backup** gibt ein gestohlenes Backup alle Private Keys
> preis. Deshalb niemals beides am selben Ort.
>
> Ohne das richtige `SSHKV_APP_SECRET` gehen TOTP des Notfall-Admins, SMTP-Passwort und
> Webhook-Geheimnis verloren (neu einrichten genügt); Private Keys hängen nur am Master-Key.
## Sichern
```bash
cd /opt/sshkv # Verzeichnis mit docker-compose.yml und .env
sudo bash deploy/sichern.sh # nach /var/backups/sshkv
sudo bash deploy/sichern.sh /mnt/sicherung/sshkv
```
Das Skript
- schreibt `datenbank-<Stand>.sql.gz` (`pg_dump --clean --if-exists`, nur das Schema `public` – die
Warteschlange legt der Worker selbst neu an),
- legt `master-key-pruefwert-<Stand>.txt` daneben: den SHA-256 der Master-Key-Datei. Er verrät nichts
über den Schlüssel, zeigt beim Zurückspielen aber, ob der richtige bereitliegt,
- behält die letzten 14 Sicherungen (`SSHKV_SICHERUNGEN_BEHALTEN`),
- sichert den Master-Key **nicht** mit.
Als täglicher Cronjob auf dem Docker-Host:
```cron
15 3 * * * root cd /opt/sshkv && bash deploy/sichern.sh >/var/log/sshkv-sicherung.log 2>&1
```
## Master-Key sichern (einmalig)
```bash
sudo base64 /opt/sshkv/geheim/master.key # Ausgabe in den Passwortmanager, Eintrag „sshkv Master-Key“
sudo sha256sum /opt/sshkv/geheim/master.key # Prüfwert dazuschreiben
```
Zurück in eine Datei: `echo '<base64>' | base64 -d > geheim/master.key && chmod 400 geheim/master.key`
und den Eigentümer setzen (Container-Benutzer 1001 oder `SSHKV_WORKER_USER`).
## Wiederherstellen
```bash
cd /opt/sshkv
sudo bash deploy/wiederherstellen.sh /var/backups/sshkv # jüngste Sicherung
sudo bash deploy/wiederherstellen.sh /var/backups/sshkv 20261012-163958
```
Das Skript
1. prüft den Master-Key unter `SSHKV_MASTER_KEY_HOST_FILE` gegen den Prüfwert der Sicherung und
**bricht ab, wenn er nicht passt**,
2. fragt nach (`--ja` überspringt),
3. hält Weboberfläche und Worker an,
4. spielt die Datenbank **in einer Transaktion** zurück – scheitert etwas, bleibt der alte Stand
vollständig erhalten,
5. setzt die Warteschlange zurück und bricht offene Vorgänge ab (ihre Jobs gehören nicht zur
Sicherung),
6. startet die Dienste und prüft, dass der Worker den Master-Key akzeptiert.
Zusätzlich prüft der **Worker bei jedem Start**, ob er den Deployment-Key mit dem eingebundenen
Master-Key entschlüsseln kann, und startet sonst nicht („Der Master-Key passt nicht zur Datenbank“).
### Geprobt am 12.10.2026
Auf einem Wegwerf-Stapel (gleiches `docker-compose.yml`):
1. Markierung angelegt (Host `srv-probe`, Protokolleintrag), gesichert.
2. Markierung gelöscht, weiterer Protokolleintrag geschrieben.
3. Zurückgespielt → Host wieder da, Protokoll wieder auf dem gesicherten Stand, Hash-Kette
unversehrt (`protokoll_kette_pruefen()`), Weboberfläche gesund, Worker bereit.
4. Mit **falschem Master-Key** zurückgespielt → Abbruch vor jeder Änderung.
Dabei fielen zwei Fehler auf, die jetzt behoben sind: Das Schema der Warteschlange ließ sich mit
`--clean` nicht zurückspielen, und ein Abbruch ohne Transaktion hatte es halb gelöscht.
## Nach einer Wiederherstellung
- **„Jetzt abgleichen“** auf der Übersicht: Der Stand der Hosts kann neuer sein als die Sicherung.
- Zuweisungen, die nach der Sicherung ausgerollt wurden, kennt sshkv nicht mehr – der Abgleich zeigt
sie als unbekannte Schlüssel; übernehmen oder entfernen.
- Wurde nach der Sicherung ein Schlüssel widerrufen, steht er wieder als aktiv da: erneut widerrufen.
+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.
+12 -5
View File
@@ -1,20 +1,27 @@
# #021 Abgleich und Drift-Erkennung # #021 Abgleich und Drift-Erkennung
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.4.0 **Meilenstein:** v0.4.0
**Labels:** backend **Labels:** backend
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 45 min
## Ziel ## Ziel
Periodischer und manueller Abgleich, Befunde je Host/Benutzer. Periodischer und manueller Abgleich, Befunde je Host/Benutzer.
## Aufgaben ## Aufgaben
- [ ] Zeitplan konfigurierbar - [x] Zeitplan konfigurierbar
- [ ] Befundarten - [x] Befundarten
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Integrationstest - [x] Integrationstest
## Abhängigkeiten ## Abhängigkeiten
#016 #016
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Ein SSH-Aufruf je Host** liest `authorized_keys` aller Benutzer mit UID 0 oder ≥ 1000 plus aller Benutzer mit Zuweisungen – als jeweiliger Benutzer, base64-kodiert (Zeilenenden und fremde Bytes kommen unversehrt an). Dazu Rechte und Eigentümer von `~/.ssh` und Datei.
- **Befunde** (rein, `src/lib/ssh/abgleich.ts`): fehlender Key im Block, Block von Hand geändert (zusätzlicher Key oder andere Optionen), unbekannter Key außerhalb (bekannte Keys außerhalb sind kein Befund), Rechte (für Gruppe/Welt beschreibbar oder fremder Eigentümer), beschädigter Block, Private Key fehlt oder verändert (Hash gegen den versiegelten Key).
- **Abweichungen** werden geöffnet und – sobald behoben – geschlossen, nie doppelt. Host-Key-Abweichungen bleiben bei #009.
- **Zustände:** Public-Verwendungen AUSGEROLLT ↔ ABWEICHUNG, GEPLANT bleibt bis zum Ausrollen; Host SYNCHRON/DRIFT, letzter Abgleich, erreichbar.
- **Zeitplan** über pg-boss (`SSHKV_SYNC_CRON`, Standard `30 2 * * *`, Europe/Berlin) – liegt in der Datenbank, feuert also auch bei mehreren Workern nur einmal. Hosts mit laufendem Abgleich werden übersprungen.
- **Ändert nichts auf dem Host.** Korrekturen laufen über Dry-Run und Ausrollen.
- `DATEI_GEAENDERT` meldet nur einen beschädigten Block; eine bloße Hash-Änderung ist kein Befund, weil sshkv selbst schreibt.
+8 -4
View File
@@ -1,19 +1,23 @@
# #022 Import bestehender Schlüssel # #022 Import bestehender Schlüssel
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.4.0 **Meilenstein:** v0.4.0
**Labels:** backend, ui **Labels:** backend, ui
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
## Ziel ## Ziel
Unbekannte Schlüssel eines Hosts übernehmen und zuordnen. Unbekannte Schlüssel eines Hosts übernehmen und zuordnen.
## Aufgaben ## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert - [x] wird zu Beginn der Phase verfeinert
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert - [x] wird zu Beginn der Phase verfeinert
## Abhängigkeiten ## Abhängigkeiten
#021 #021
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Grundlage ist die letzte Beobachtung** des Abgleichs: Übernommen wird nur, was sshkv dort wirklich gesehen hat.
- **Zwei Wege:** „bekannt“ (nur als Schlüssel aufnehmen – der Befund verschwindet, auf dem Host ändert sich nichts) oder „verwalten“ (Zuweisung mit `ausserhalbEntfernen`: beim nächsten Ausrollen wandert die Zeile mit ihren Optionen in den Block, die alte Zeile außerhalb verschwindet – mit Dry-Run).
- Neues Feld `PublicVerwendung.ausserhalbEntfernen` (Migration `20261012131000`). Ohne dieses Feld fasst sshkv Zeilen außerhalb des Blocks weiterhin nie an.
- Optionen der Zeile werden mit `optionenPruefen` übernommen; unbekannte Optionen verhindern das Verwalten mit klarer Meldung.
+32 -5
View File
@@ -1,19 +1,46 @@
# #023 Geführte Rotation # #023 Geführte Rotation
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.4.0 **Meilenstein:** v0.4.0
**Labels:** backend, ui **Labels:** backend, ui
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 20 min
## Ziel ## Ziel
Neuer Schlüssel an allen Verwendungen, Test, alter entfernt. Neuer Schlüssel an allen Verwendungen, Test, alter entfernt.
## Aufgaben ## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert - [x] „Rotieren“ auf der Schlüsselseite, Schrittfolge `/schluessel/[id]/rotieren` (wiederaufnehmbar)
- [x] Neuer Schlüssel gleichen Typs, Name „… (rotiert TT.MM.JJJJ)“, `vorgaengerId` = alter
- [x] Zuweisungen übernehmen: Public gleiche Ziele/Optionen, Private gleicher Host/Benutzer/Alias mit neuem Pfad
- [x] Dry-Run + Ausrollen, Verbindungstests, erst dann den alten widerrufen (#024) → `ROTIERT`
- [x] Personen-Key ohne Private Key: nur Hinweis „neuer Public Key nötig“
- [x] Rotationskette auf der Schlüsselseite (Vorgänger/Nachfolger verlinkt)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert - [x] DB-Tests: Schritte, Reihenfolge, Rechte, Namen/Pfade (`tests/rotation.test.ts`)
- [x] Integrationstest: neuer Schlüssel funktioniert, alter von beiden Hosts weg, `ssh <alias>` geht weiter
- [x] E2E im Browser (`tests/e2e/rotation.spec.ts`), Screenshot `rotation-desktop.png`
## Abhängigkeiten ## Abhängigkeiten
#020 #020, #024
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Fünf Schritte:** Neuer Schlüssel → Zuweisungen übernehmen → Dry-Run & Ausrollen → Verbindungstests →
Alten widerrufen. Der aktuelle Schritt wird aus der Datenbank errechnet (`rotationsStand`), deshalb
lässt sich die Rotation jederzeit neu laden und fortsetzen; jeder Schritt ist idempotent.
- **Kette sofort:** Der Worker setzt `vorgaengerId` schon beim Erzeugen (neues Feld in der
Vorgangs-Eingabe) – auch ein abgebrochener Browser hinterlässt keinen verwaisten Schlüssel.
`vorgaengerId` ist eindeutig: zwei gleichzeitige Rotationen scheitern verständlich.
- **Gleicher Alias, zwei Zuweisungen:** `privat.ts` schreibt je Alias genau einen config-Eintrag, die
jüngste Zuweisung gewinnt (an der Position der ersten). So zeigt `ssh <alias>` nach dem Ausrollen auf
die neue Datei, während die alte bis zum Widerruf daneben liegt.
- **Neuer Pfad:** `<alter Pfad ohne -rJJJJMMTT>-rJJJJMMTT`, bei Kollision mit laufender Nummer –
auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index über alle Zustände).
- **Grün heißt aktuell:** Der alte Schlüssel wird nur widerrufen, wenn für jede Private-Zuweisung mit
Alias ein erfolgreicher `VERBINDUNGSTEST` nach dem letzten Ausrollen vorliegt (Server prüft das).
Ohne Private-Zuweisung mit Alias entfällt der Test.
- **Abschluss:** Widerruf aus #024 ohne Zielkonten (der Nachfolger nutzt sie weiter); ein aktiver
Nachfolger macht aus `WIDERRUFEN` den Status `ROTIERT`.
- Ablaufdatum: Der Nachfolger erbt es nur, wenn es in der Zukunft liegt. Eine Passphrase wird nicht
übernommen (sshkv erzeugt ohne).
- Rechte: `schluesselVerwalten` + `publicKeyAusrollen`, bei Private-Zuweisungen `privateKeyAusrollen`.
Deployment-Key: eigene Rotation (später).
+34 -4
View File
@@ -1,19 +1,49 @@
# #024 Widerruf und Massenwiderruf # #024 Widerruf und Massenwiderruf
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.4.0 **Meilenstein:** v0.4.0
**Labels:** backend, ui, sicherheit **Labels:** backend, ui, sicherheit
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
## Ziel ## Ziel
Ein-Klick-Widerruf inkl. Benutzer/sudoers; Massenwiderruf mit Re-Auth. Ein-Klick-Widerruf inkl. Benutzer/sudoers; Massenwiderruf mit Re-Auth.
## Aufgaben ## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert - [x] „Widerrufen“ auf der Schlüsselseite mit Dialog, der jede Entfernung auflistet
- [x] Public- und Private-Verwendungen → ENTFERNEN, Dry-Run, anwenden, erst dann `WIDERRUFEN`
- [x] Zielkonten optional entfernen (nur `vonSshkvAngelegt`, nie ein noch genutztes)
- [x] Massenwiderruf auf der Schlüsselliste, ein gemeinsamer Dry-Run, Protokoll je Schlüssel
- [x] Abgelaufene Schlüssel erkennbar (Filter) und Widerruf anbieten – ohne automatisches Entfernen
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert - [x] DB-Tests: Zustände, Rechte, Zielkonten, Protokoll ohne Geheimnisse (`tests/widerruf.test.ts`)
- [x] Integrationstest gegen zwei Hosts inkl. „Host nicht erreichbar“ (`tests/integration/widerruf.test.ts`)
- [x] E2E im Browser (`tests/e2e/widerruf.spec.ts`), Screenshot `widerruf-desktop.png`
## Abhängigkeiten ## Abhängigkeiten
#018 #018
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Zwischenzustand statt Sofort-Status:** neues Feld `Schluessel.widerrufEingeleitetAm` (Migration
`20261012130000_widerruf_eingeleitet`). Der Status bleibt AKTIV/ABGELAUFEN, bis keine Verwendung mehr
offen ist; erst `widerrufAbschliessen` setzt `WIDERRUFEN` (bzw. `ROTIERT`, wenn ein aktiver Nachfolger
existiert, #023). Schlüsselseite und Liste zeigen „Widerruf unvollständig“ und die offenen Ziele.
- **Reihenfolge:** vormerken → Dry-Run/Anwenden (bestehende Hash-Sperre aus #016/#017) → optional
Zielkonten (`ZIELKONTO_ENTFERNEN`, erst wenn die Schlüssel überall weg sind) → abschließen.
Ein Ziel, das im Dry-Run scheitert (nicht erreichbar), wird nicht angewendet; seine Verwendung bleibt
ENTFERNEN, ein neuer Dry-Run versucht es erneut. Der Abschluss läuft nach erfolgreichem Anwenden
automatisch und meldet, was noch fehlt.
- **Nichts zu schreiben:** Steht der Schlüssel schon nirgends mehr, bietet das Dry-Run-Panel
„Zustand übernehmen“ an (sonst bliebe der Widerruf hängen).
- **Archivierte Hosts:** sshkv verbindet sich dort nicht mehr – deren Verwendungen gelten sofort als
entfernt; der Dialog sagt das, das Protokoll nennt die Hosts.
- **Zielkonten:** Ein Zielkonto kennt seinen Schlüssel nicht; Kandidat ist ein von sshkv angelegtes Konto
auf (Host, Benutzer) einer Zuweisung der widerrufenen Schlüssel. Nutzt ein anderer, nicht im Widerruf
befindlicher Schlüssel das Konto noch, bleibt es (Dialog nennt den Schlüssel).
- **Rechte:** nur Public Keys → `publicKeyAusrollen`; Private Keys → `privateKeyAusrollen` (Admin);
mehrere Schlüssel → `massenwiderruf`, Zielkonten → `zielkontenVerwalten` (jeweils Admin mit frischer
Bestätigung). Der Deployment-Key ist nicht widerrufbar. Ein Schlüssel im Widerruf lässt sich nicht
mehr zuweisen.
- **Ablauf:** Status-Filter „abgelaufen“ umfasst aktive Schlüssel mit vergangenem Ablaufdatum; die
Schlüsselseite bietet dann „Jetzt widerrufen“. Entfernt wird nie automatisch.
- Massenwiderruf als GET-Formular `/schluessel/widerruf?id=…` – die Auswahl der Zielkonten reist als
`?zielkonten=` mit und übersteht Neuladen und erneute Bestätigung.
+31 -4
View File
@@ -1,19 +1,46 @@
# #025 Zuweisungsmatrix # #025 Zuweisungsmatrix
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.4.0 **Meilenstein:** v0.4.0
**Labels:** ui **Labels:** ui
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0,5 h
## Ziel ## Ziel
Matrix Schlüssel × Host. Matrix Schlüssel × Host.
## Aufgaben ## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert - [x] `/zuweisungen` ersetzt die Platzhalterseite: Matrix aktive Schlüssel (Zeilen) × nicht archivierte Hosts (Spalten), jede Rolle darf lesen
- [x] Zelle: „P“ direkt, „P·G“ über Hostgruppe, „K“ Private Key; Zustand als Punkt + Kurztext, voller Text in `title` und für Screenreader; Zielbenutzer mono
- [x] Filter per GET: Hostgruppe, Kategorie, Person, Suche über Schlüssel- und Hostnamen, „nur Zeilen/Spalten mit Zuweisungen“
- [x] Eigener Scrollbereich mit sticky Kopfzeile und erster Spalte; unter `md` eine Liste je Schlüssel
- [x] Zell-Dialog: Verwendungen mit Zustand, Optionen/Pfad, Links zu Schlüssel und Host; „Public Key zuweisen“ (vorhandenes Formular mit Vorauswahl) und „Entfernen“ für `publicKeyAusrollen`; Link „Zum Dry-Run“
- [x] Kopf: Zähler (Schlüssel, Hosts, Zuweisungen, nicht synchron) und CSV-Export `/api/zuweisungen.csv`
- [x] Reine Matrix-Funktion in `src/lib/zuweisungsmatrix.ts`, CSV-Helfer in `src/lib/csv.ts`, Query in `src/server/queries/zuweisungen.ts`
- [x] Tests: `tests/zuweisungsmatrix.test.ts` (rein), `tests/zuweisungen.test.ts` (Query + CSV-Route), E2E `tests/e2e/zuweisungen.spec.ts`
- [x] Screenshots `zuweisungen-desktop.png` / `zuweisungen-mobil.png`
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert - [x] Gruppen-Zuweisungen erscheinen auf jedem Mitglied und sind von direkten unterscheidbar
- [x] Zustand nie nur über Farbe
- [x] Seite scrollt bei 390 px nicht horizontal (Screenshot-Skript prüft das)
- [x] Leser sehen die Matrix, aber weder Zuweisen noch Entfernen
- [x] CSV mit den Spalten Schlüssel, Fingerprint, Host, Zielbenutzer, Art, Über Gruppe, Optionen, Zustand; Formelbeginne `= + - @` mit `'` entschärft
- [x] Route Handler prüft `rechtErforderlich("lesen")` selbst (`tests/zugang.test.ts` grün)
## Abhängigkeiten ## Abhängigkeiten
#016 #016
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Die Matrix plant nur: Zuweisen und Entfernen nutzen die vorhandenen Aktionen, ausgerollt wird
weiter über den Dry-Run der Schlüsselseite (`AusrollenPanel`).
- Suche: trifft der Begriff Schlüssel, werden die Zeilen eingeschränkt; trifft er Hosts, die
Spalten; trifft er beides, beides. So zeigt „srv-pve“ alle Schlüssel auf diesen Hosts statt
einer leeren Matrix.
- Zähler „Zuweisungen“ zählt Zelleneinträge, eine Gruppen-Zuweisung also einmal je Mitglied –
dasselbe, was der CSV-Export als Zeilen liefert. „Nicht synchron“ ist alles außer `AUSGEROLLT`.
- Zellenzustand ist der dringendste der Einträge (Fehler > Drift > entfernen > geplant > synchron).
- CSV: Semikolon, UTF-8-BOM und CRLF für ein deutsches Excel; bei Private Keys steht der Pfad
als „Pfad: …“ in der Spalte Optionen. Der Export nimmt dieselben Filter wie die Seite.
- Deployment-Keys erscheinen als Zeilen, sind aber nicht zuweisbar (verwaltet sshkv selbst).
- Entfernen einer Gruppen-Zuweisung aus einer Zelle gilt für die ganze Gruppe; der Dialog sagt das.
- `PublicZuweisenFormular` hat eine optionale `vorauswahl`; `VerwendungsZustand` exportiert seine
Darstellungstabelle (`ZUSTAND_ANZEIGE`), damit die Zelle dieselben Punkte nutzt.
+9 -4
View File
@@ -1,19 +1,24 @@
# #026 Übersicht (Dashboard) # #026 Übersicht (Dashboard)
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.5.0 **Meilenstein:** v0.5.0
**Labels:** ui **Labels:** ui
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0 h 25 min
## Ziel ## Ziel
Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen. Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen.
## Aufgaben ## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert - [x] Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen aus der Datenbank
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert - [x] Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen aus der Datenbank
## Abhängigkeiten ## Abhängigkeiten
#021 #021
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Alles berechnet, nichts hinterlegt** (`src/server/queries/uebersicht.ts`): Kacheln und Listen können einander nicht widersprechen. Die Prototyp-Beispieldaten sind entfernt.
- **Hosts mit Abweichung** fasst Befunde je Host und Benutzer zusammen („2 unbekannte Schlüssel · Rechte falsch“, feste Reihenfolge) und zeigt nicht erreichbare Hosts; Link direkt auf die Hostseite.
- **Bald ablaufend** zeigt auch bereits abgelaufene, noch aktive Schlüssel („abgelaufen · noch ausgerollt“ in Koralle).
- **Letzte Aktionen** ohne Anmeldungen – die stehen im Audit-Log.
- Knöpfe nur für passende Rollen (Abgleich: Hosts verwalten; Assistent: Private Keys ausrollen).
+25 -4
View File
@@ -1,19 +1,40 @@
# #027 Protokollansicht, CSV-Export, Syslog # #027 Protokollansicht, CSV-Export, Syslog
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.5.0 **Meilenstein:** v0.5.0
**Labels:** ui, backend **Labels:** ui, backend
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
## Ziel ## Ziel
Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog. Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog.
## Aufgaben ## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert - [x] `/protokoll` statt Platzhalter, jede Rolle liest: Tabelle mit Zeitpunkt (TT.MM.JJJJ HH:MM:SS), Aktion (lesbar, farbig), Akteur, Host, Zielbenutzer, Fingerprint (gekürzt), Ergebnis; unter `md` Karten
- [x] Zeile aufklappbar: Diff im Diff-Box-Stil, Details als Liste (Werte als JSON), Hash und Vorgänger-Hash
- [x] Lesbare Texte für alle Aktionen in `src/lib/protokoll-aktionen.ts`, gruppiert (Anmeldung, Host-Key, Ausrollen, Schlüssel, Widerruf/Rotation, Zielkonto, Abgleich, Hosts, Personen und Vorlagen, Protokoll)
- [x] Filter per GET: Zeitraum (Berliner Kalendertage), Aktionsgruppe, Akteur, Host, Ergebnis, Freitext (Fingerprint/Host/Benutzer)
- [x] 50 je Seite, „Ältere laden“ hängt per Server Action an; Cursor über `id`, kein `OFFSET`; `?vor=<id>` als Einstieg
- [x] Kopfzeile mit Anzahl und Syslog-Ziel; „Kette prüfen“ (Server Action) mit Ergebnis-Zwischenspeicher für 5 Minuten, Warnung mit Link auf die erste fehlerhafte ID
- [x] CSV-Export `/api/protokoll.csv`: eigene Rechteprüfung, gleiche Filter, gestreamt in Stapeln zu 500, Formel-Entschärfung über `src/lib/csv.ts`, Export selbst als `protokoll.exportiert` protokolliert
- [x] Kein Lösch-/Bearbeitungsweg; struktureller Test gegen `protokoll.update/delete…` und `UPDATE/DELETE/TRUNCATE "Protokoll"` in `src/`
- [x] Syslog nach RFC 5424 (`src/lib/syslog.ts`, UDP oder TCP mit Oktettzählung, ohne neue Abhängigkeit), eingehängt in `protokollieren()` nach dem Schreiben
- [x] `.env.example` und `docker-compose.yml` (web und worker) um `SSHKV_SYSLOG_*`
- [x] Tests: Query/Cursor/Filter, CSV-Route, Kettenprüfung, Syslog (Format, Escaping, Länge, UDP-/TCP-Empfänger), Aktionstexte, Struktur; E2E `tests/e2e/protokoll.spec.ts`
- [x] Screenshots `protokoll-desktop.png` und `protokoll-mobil.png`
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert - [x] Jede `aktion: "…"` im Quellcode hat einen lesbaren Text (Test)
- [x] Filter und „Ältere laden“ liefern weder Lücken noch Doppelte, auch wenn zwischendurch Einträge dazukommen
- [x] CSV ohne Sitzung → 403; Kopfzeile `id;zeitpunkt;akteur;aktion;…;hash`; große Protokolle werden in Stapeln gelesen
- [x] Manipulierte Zeile → Warnung mit ID; unversehrte Kette → „Hash-Kette unversehrt“
- [x] Ausgefallener Syslog-Server verhindert keine Aktion; Fehler höchstens einmal je Minute im Log
- [x] 1440 und 390 px ohne Querscrollen
## Abhängigkeiten ## Abhängigkeiten
#005 #005
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Kettenprüfung nur auf Knopfdruck, 5 Minuten zwischengespeichert (je Prozess).** `protokoll_kette_pruefen()` rechnet SHA-256 über jede Zeile und wächst linear mit dem Protokoll; beim bloßen Seitenaufruf wäre das bei 100 000 Einträgen spürbar. Gleichzeitige Klicks teilen sich eine laufende Prüfung. Ein Bruch wird selbst als `protokoll.kette_beschaedigt` (FEHLER) protokolliert, damit er auch beim Syslog ankommt.
- **CSV gestreamt** über einen `ReadableStream` mit `pull`: je Stapel 500 Zeilen (Cursor über `id`), der nächste Stapel erst, wenn der Client den vorigen abgenommen hat. Der Exporteintrag wird vor dem ersten Byte geschrieben – scheitert er, gibt es keinen Export.
- **IDs sind nicht lückenlos** (Spalten-Default und Trigger ziehen beide aus der Sequenz); Cursor und Tests verlassen sich nur auf die Ordnung.
- **Syslog:** RFC 5424, SD-ID `sshkv@32473`, Parameter id/akteur/aktion/ergebnis/host/zielbenutzer/fingerprint/hash (escaped nach §6.3.3, Steuerzeichen ersetzt, je Wert ≤ 120 Byte, Zeile ≤ 2048 Byte), MSGID = Aktion, Severity ERFOLG=notice, VERWEIGERT=warning, FEHLER=err, Facility standardmäßig `authpriv` (`SSHKV_SYSLOG_FACILITY`). Diff und Details gehen nie hinaus. Fire-and-forget nach dem `create`; innerhalb einer Transaktion kann ein späterer Rollback eine Syslog-Zeile ohne Gegenstück hinterlassen – das ist die harmlosere Richtung (ihr Hash fehlt dann in der Kette).
- `--ok` nur für erfolgreiche Aktionen, die etwas ändern; Anmeldungen, Abrufe und Export bleiben neutral, Fehler/Verweigert immer `--danger`.
+50 -4
View File
@@ -1,19 +1,65 @@
# #028 Benachrichtigungen # #028 Benachrichtigungen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.5.0 **Meilenstein:** v0.5.0
**Labels:** backend **Labels:** backend
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0,5 h
## Ziel ## Ziel
E-Mail (M365 SMTP) und Webhook bei Drift und Ablauf. E-Mail (M365 SMTP) und Webhook bei Drift und Ablauf.
## Aufgaben ## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert - [x] Einstellungsseite `/einstellungen` statt Platzhalter: lesen alle Rollen, ändern nur
`einstellungenVerwalten` (Admin). Abschnitte „Benachrichtigungen“, „Abgleich“ (Zeitpläne aus
`SSHKV_SYNC_CRON`/`SSHKV_MELDUNG_CRON`, nur Anzeige) und „Instanz“ (Version,
Deployment-Key-Fingerprint, OIDC-Anbieter, TOTP des Notfall-Admins). Platz für „Syslog“ markiert.
- [x] Kanal E-Mail (SMTP): Vorbelegung Microsoft 365 (`smtp.office365.com:587`, STARTTLS), wahlweise
TLS (465) oder unverschlüsselt (nur internes Relay, mit Warnung); Absender, Benutzer, Passwort,
Empfängerliste.
- [x] Kanal Webhook: JSON per POST, optional `X-Sshkv-Signatur: sha256=<HMAC>` über den Body; nur
`https://`, `http://` nur per Häkchen mit Warnung; keine Weiterleitungen, Zeitlimit 15 s.
- [x] Geheimnisse (SMTP-Passwort, Webhook-Geheimnis) mit `verschluesseln()` in `Einstellung`;
die Oberfläche bekommt nur „gesetzt“, leeres Feld behält, Häkchen entfernt.
- [x] „Testnachricht senden“ je Kanal als Vorgang `BENACHRICHTIGUNG_TEST` im Worker.
- [x] Anlässe (je einzeln schaltbar): neue Abweichungen je Host, Ablauf in 30/14/7 Tagen,
abgelaufen aber ausgerollt, Host nicht erreichbar – gesammelt in der täglichen Zusammenfassung
(`SSHKV_MELDUNG_CRON`, Standard `0 7 * * *`); geänderter Host-Key sofort (Takt alle 5 Minuten).
- [x] Versandprotokoll `Zustellung` (Migration `20261012161000_benachrichtigungen`) mit
Anlass-Fingerabdruck, Kanal, Ergebnis, Fehler, Versuchen; Prüfung vor jedem Versand,
Wiederholung nach 5/10/20/40 Minuten (höchstens 5 Versuche); „Letzte Zustellungen“ auf der Seite.
- [x] Jede Zustellung in `protokollieren()` als `benachrichtigung.gesendet`/`…fehlgeschlagen`
(Empfänger nur gezählt, Webhook nur Schema und Host).
- [x] Zeitpläne in `src/worker/index.ts`; `.env.example` und `docker-compose.yml` ergänzt.
- [x] Tests: Unit (Nachrichtenaufbau, Signatur, Stufen, Deduplizierung, Prüfungen), DB
(Einstellungen, Versandprotokoll), Integration gegen Mailpit und lokalen HTTP-Empfänger,
E2E `tests/e2e/einstellungen.spec.ts`; Screenshots `einstellungen-desktop/-mobil`.
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert - [x] Admin speichert SMTP-Einstellungen, die Testnachricht kommt in Mailpit an (E2E).
- [x] Das Passwort steht nie im Klartext in Datenbank, Protokoll oder Seite; das Feld zeigt nur „gesetzt“.
- [x] Leser sieht die Seite ohne Ändern- und Test-Knöpfe.
- [x] Dieselbe Abweichung, dieselbe Ablaufstufe und derselbe Ausfall werden je Kanal genau einmal gemeldet.
- [x] Fehlgeschlagene Zustellungen werden begrenzt wiederholt und sind in der Oberfläche sichtbar.
- [x] Ein geänderter Host-Key wird innerhalb von fünf Minuten gemeldet, ohne den Abgleich-Ablauf zu ändern.
## Abhängigkeiten ## Abhängigkeiten
#021 #021
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Versand im Worker**, auch für die Testnachricht: der Webprozess braucht so keinen Weg zu
Mailserver oder Webhook-Ziel, und regulärer Versand und Test laufen über denselben Code. Die
Oberfläche verfolgt den Test wie andere Vorgänge über `VorgangsKnopf`.
- **Anlässe aus der Datenbank**, nicht aus dem Abgleich: `src/worker/ablaeufe/abgleich.ts` bleibt
unverändert; der Sammler liest offene `Abweichung`, `Host.erreichbar = false` und Ablaufdaten.
Ein verpasster Lauf holt sich der nächste.
- **Fingerabdrücke:** `drift:<abweichungId>`, `hostkey:<abweichungId>`,
`ablauf:<schluesselId>:<stufe>:<ablaufdatum>` (mit Datum, damit ein verlängerter Schlüssel neu
erinnert wird), `abgelaufen:<schluesselId>:<ablaufdatum>`,
`unerreichbar:<hostId>:<letzterAbgleichAm>` (ändert sich nur bei gelungenem Abgleich → eine Meldung
je Ausfall). Statt „seit der letzten Meldung“ zählt das Versandprotokoll – wer Benachrichtigungen
erstmals einschaltet, bekommt die offenen Abweichungen einmal gemeldet.
- Hosts mit offenem `HOSTKEY_GEAENDERT` erscheinen nicht zusätzlich als „nicht erreichbar“.
- Der Fünf-Minuten-Takt verschickt dringende Host-Key-Meldungen und fällige Wiederholungen;
Neues aus (a)–(d) wartet auf die Zusammenfassung. Läufe im selben Worker laufen nacheinander.
- Leser sehen statt der Webhook-Adresse nur Schema und Host (Tokens im Pfad).
- Mailpit für Tests mit `MP_SMTP_DISABLE_RDNS=true` starten, sonst wartet es je Verbindung 5 s.
- `nodemailer@10.0.10` wie in ProxLedger, exakt gepinnt.
+14 -4
View File
@@ -1,19 +1,29 @@
# #029 Handbuch und Betriebsdokumentation # #029 Handbuch und Betriebsdokumentation
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.5.0 **Meilenstein:** v0.5.0
**Labels:** docs **Labels:** docs
**Geschätzt:** 3 h **Tatsächlich:** – **Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
## Ziel ## Ziel
Installation, Bootstrap, Kern-Anwendungsfall mit Bildern; Bedrohungsanalyse, Backup-Konzept. Installation, Bootstrap, Kern-Anwendungsfall mit Bildern; Bedrohungsanalyse, Backup-Konzept.
## Aufgaben ## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert - [x] Handbuch mit Bildern (`docs/anleitung/README.md`, 12 Kapitel)
- [x] Installation, authentik, Reverse Proxy (`docs/betrieb/installation.md`)
- [x] Bedrohungsanalyse und Notfallplan (`docs/betrieb/sicherheit.md`)
- [x] Backup-Konzept mit Skripten und geprobter Wiederherstellung (`docs/betrieb/sicherung.md`)
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert - [x] Handbuch mit Bildern (`docs/anleitung/README.md`, 12 Kapitel)
- [x] Installation, authentik, Reverse Proxy (`docs/betrieb/installation.md`)
- [x] Bedrohungsanalyse und Notfallplan (`docs/betrieb/sicherheit.md`)
- [x] Backup-Konzept mit Skripten und geprobter Wiederherstellung (`docs/betrieb/sicherung.md`)
## Abhängigkeiten ## Abhängigkeiten
– –
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- **Wiederherstellung geprobt** auf einem Wegwerf-Stapel – dabei zwei Fehler gefunden und behoben (Schema pgboss mit `--clean` nicht rückspielbar; Abbruch ohne Transaktion löschte es halb). Jetzt: nur Schema `public`, eine Transaktion, Warteschlange neu, offene Vorgänge abgebrochen.
- **Master-Key wird nicht mitgesichert**, nur ein Prüfwert (SHA-256). Das Wiederherstellungsskript bricht vor jeder Änderung ab, wenn der bereitliegende Master-Key nicht passt; der Worker startet nicht mit einem falschen Master-Key.
- **Notfallplan „sshkv kompromittiert“** beschreibt das Entfernen des Deployment-Keys auf allen Hosts von Hand – eine eingebaute Rotation des Deployment-Keys gibt es noch nicht.
- Bilder entstehen aus `scripts/screenshots.ts` und den E2E-Abläufen.
+6 -4
View File
@@ -1,19 +1,21 @@
# #030 Issues nach Gitea übertragen # #030 Issues nach Gitea übertragen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** chore **Labels:** chore
**Geschätzt:** 1 h **Tatsächlich:** – **Geschätzt:** 1 h **Tatsächlich:** 0 h 10 min
## Ziel ## Ziel
Issue-Dateien, Labels und Meilensteine in Gitea anlegen (wartet auf Zugangsdaten). Issue-Dateien, Labels und Meilensteine in Gitea anlegen (wartet auf Zugangsdaten).
## Aufgaben ## Aufgaben
- [ ] `scripts/gitea-issues.ts` - [x] `scripts/gitea-issues.ts`
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert - [x] wird zu Beginn der Phase verfeinert
## Abhängigkeiten ## Abhängigkeiten
– –
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Repo `florian.wilde/sshkeyvault` auf git.wilde-server.de (von Florian angelegt), `main` und Tags v0.1.0–v0.5.0 gepusht.
- `pnpm issues:sync` liest Adresse und Repo aus dem Git-Remote und den Token aus den Git-Zugangsdaten; die Issue-Dateien bleiben die Quelle der Wahrheit.
+30
View File
@@ -0,0 +1,30 @@
# #031 Rotation des Deployment-Keys
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** sicherheit, backend
**Geschätzt:** 3 h **Tatsächlich:** 1 h 10 min
## Ziel
Den Deployment-Key der Instanz geführt tauschen – regelmäßig oder im Notfall (sshkv kompromittiert, siehe `docs/betrieb/sicherheit.md`).
## Aufgaben
- [x] Neuen Deployment-Key erzeugen, ohne den alten abzulösen (zweiter Instanz-Key im Übergang)
- [x] Auf jedem eingerichteten Host den neuen Key neben den alten in den verwalteten Block des Anmeldebenutzers schreiben (Dry-Run, Ausrollen)
- [x] Verbindung mit dem neuen Key je Host prüfen
- [x] Erst wenn alle Hosts den neuen Key akzeptieren: alten aus allen Blöcken entfernen, alten Key verwerfen
- [x] Nicht erreichbare Hosts sichtbar machen; Abschluss erst, wenn alle umgestellt sind (oder bewusst übergangen)
- [x] Nur Admin mit frischer Bestätigung, alles protokolliert
## Akzeptanzkriterien
- [x] Integrationstest: nach der Rotation meldet sich sshkv nur noch mit dem neuen Key an, der alte steht auf keinem Host mehr
- [x] Aussperrschutz: zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da
## Abhängigkeiten
#016, #021
## Notizen / Entscheidungen
- Der Nachfolger ist ein eigener Schlüssel (`kategorie DEPLOYMENT`, `vorgaengerId` = alter, noch nicht `instanzStandard`). `sollFuer()` nimmt ihn für alle Hosts mit dem Instanz-Key zusätzlich in Block **und** Pflichtliste – der normale Dry-Run verteilt ihn also, und der Aussperrschutz schützt beide.
- Hosts mit eigenem oder Gruppen-Deployment-Key nehmen nicht teil.
- Umstellen ist eine Transaktion (alter ROTIERT, neuer Standard) und verlangt eine Prüfung **nach** dem Erzeugen, in der jeder Host den neuen Key angenommen hat – oder bewusst übergangen wird (protokolliert).
- Abschließen prüft je Host, dass der alte Fingerprint aus dem Block verschwunden ist; erst dann wird sein Private Key verworfen. Unerreichbare Hosts blockieren den Abschluss nicht (sshkv benutzt den alten Key ohnehin nicht mehr), werden aber im Ergebnis genannt.
- Tests: Integrationstest gegen beide Testziele (DIENSTBENUTZER und ROOT, alte Anmeldung scheitert danach), E2E im Browser.
+53
View File
@@ -0,0 +1,53 @@
# #032 Nacharbeiten aus Phase 4/5
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** test, chore
**Geschätzt:** 2 h **Tatsächlich:** 0,75 h
## Ziel
Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
## Aufgaben
- [x] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC)
- [x] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv)
- [x] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`)
- [x] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker)
## Akzeptanzkriterien
- [x] Alle Suiten grün
## Abhängigkeiten
#023, #024, #025, #027
## Notizen / Entscheidungen
- **Massenwiderruf:** Der neue E2E-Test (`widerruf.spec.ts`) fand einen echten Fehler: Die Seite
`/schluessel/widerruf` filterte `?id=` mit `/^[a-z0-9]{1,64}$/`. Vom Worker erzeugte Schlüssel tragen
eine UUID mit Bindestrichen – jeder erzeugte Schlüssel fiel still heraus („Keine Schlüssel gewählt“).
Jetzt `idsAusSuchparameter` in `src/lib/schluessel.ts` (mit Test). Außerdem nannte der Hinweis nach
verweigerter Bestätigung den falschen Knopf („Widerruf vorbereiten“ statt „2 Schlüssel widerrufen“).
`datenbank()` und `oidcBestaetigen()` liegen jetzt in `tests/e2e/hilfen.ts`.
- **Rotation abbrechen:** kein neues Feld. Abbrechen = Widerruf des Nachfolgers über den Ablauf aus #024
(Dry-Run, er kann schon ausgerollt sein); ein Nachfolger im Widerruf bzw. widerrufen *ist* die
abgebrochene Rotation (`rotationAbgebrochen`). `vorgaengerId` bleibt für die Historie, der Abbruch
steht als `schluessel.rotation.abgebrochen` im Protokoll – in derselben Transaktion wie das Vormerken
(`widerrufEinleiten` nimmt dafür optional `zusaetzlich` an). Der alte Schlüssel bleibt unverändert; bei
gleichem Alias zeigt die config nach dem Anwenden wieder auf die alte Datei (nur verbleibende
Zuweisungen landen im Block).
- Abbrechen geht nur, solange der alte Schlüssel noch nicht entfernt wird (Schritt 5) – sonst wären am
Ende beide halb entfernt. Auf dem Nachfolger ersetzt „Rotation abbrechen“ den normalen
„Widerrufen“-Knopf, damit der Abbruch immer im Protokoll steht.
- `widerrufAbschliessen` setzt `ROTIERT` nur noch bei einem aktiven Nachfolger, der nicht selbst im
Widerruf ist; die Seite schreibt „Ersetzt durch …“ nur bei `ROTIERT`.
- **Kein zweiter Nachfolger:** `vorgaengerId` ist eindeutig (1:1-Relation). Nach einem Abbruch lässt sich
derselbe Schlüssel deshalb nicht erneut über sshkv rotieren; Seite und Fehlermeldung sagen das
(neuen Schlüssel anlegen, zuweisen, alten widerrufen). Eine erneute Rotation bräuchte eine
1:n-Relation mit eindeutigem Teilindex „ein nicht widerrufener Nachfolger je Vorgänger“.
→ Behoben in #033 (partieller Index statt 1:1).
- **Protokoll-IDs:** Migration `20261012173000_protokoll_id_lueckenlos` setzt den Spalten-Default auf den
Platzhalter `0` (Prisma: `@default(0)`, `create` ohne ID geht weiter); die ID zieht nur noch der
Trigger unter der Advisory-Sperre aus der weiterhin zugeordneten Sequenz. Bestehende Zeilen bleiben
unverändert (die ID steckt in der Hash-Eingabe), alte Lücken bleiben also stehen. Eine mitgegebene ID
überschreibt der Trigger weiterhin.
- **Anker:** Die Dry-Run-Karte trägt `id="dry-run"` (`Karte` nimmt eine optionale `id`) und
`scroll-mt-20` wegen der festen Kopfleiste der schmalen Ansicht; „Zum Dry-Run“ zeigt auf
`/schluessel/<id>#dry-run`.
+27
View File
@@ -0,0 +1,27 @@
# #033 Erneut rotieren nach abgebrochener Rotation
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** backend, datenbank
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
## Ziel
Nach einer abgebrochenen Rotation (#032) lässt sich derselbe Schlüssel erneut rotieren, statt dass ein neuer Schlüssel angelegt, zugewiesen und der alte widerrufen werden muss.
## Aufgaben
- [x] `vorgaengerId` nicht mehr eindeutig; stattdessen partieller Index: je Vorgänger höchstens ein Nachfolger, der weder widerrufen ist noch gerade widerrufen wird
- [x] Relation 1:n (`nachfolgerListe`); maßgeblich ist der jüngste Nachfolger (`JUENGSTER`, `mitNachfolger`)
- [x] Neue Rotation erst, wenn der Abbruch abgeschlossen ist (Nachfolger WIDERRUFEN)
- [x] Schlüsselseite: „Rotieren“ wieder anbieten, Hinweistext angepasst
## Akzeptanzkriterien
- [x] DB-Test: Abbruch → während des Abbruchs keine neue Rotation → danach zweiter Nachfolger, Kette zeigt ihn
- [x] DB-Test: die Datenbank lehnt einen zweiten offenen Nachfolger ab
- [x] E2E: nach dem Abbruch führt „Rotieren“ wieder zu Schritt 1
## Abhängigkeiten
#023, #032
## Notizen / Entscheidungen
- **Abgebrochene Nachfolger bleiben** mit `vorgaengerId` in der Historie; die Rotationskette folgt dem jüngsten Nachfolger.
- **Kein neuer Rotations-Zustand:** Ist der jüngste Nachfolger vollständig widerrufen und der alte nicht ROTIERT, steht die Rotation wieder bei Schritt 0.
- Prisma kennt partielle Indizes nicht; `migrate diff` bleibt trotzdem leer, weil der Index nur in der Migration steht (wie beim Instanz-Standard).
+23
View File
@@ -0,0 +1,23 @@
# #034 Abnahme-Checkliste für 1.0.0
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** docs
**Geschätzt:** 0,5 h **Tatsächlich:** 0 h 10 min
## Ziel
Was die automatischen Tests nicht abdecken – echter authentik, echte Hosts, Betrieb auf dem Zielserver – als abhakbare Liste mit erwartetem Ergebnis je Punkt, damit die Abnahme von 1.0.0 vollständig und nachvollziehbar ist.
## Aufgaben
- [x] `docs/betrieb/abnahme.md`: Installation, authentik, Notfall-Admin, Hosts, Ausrollen, Drift/Rotation/Widerruf, Audit-Log/Benachrichtigungen, Sicherung, Oberfläche
- [x] Aussagen gegen den Code geprüft (Knopfnamen, Healthcheck, Ratenbegrenzung, Header)
- [x] Verlinkt im README
## Akzeptanzkriterien
- [x] Jeder Punkt nennt ein prüfbares Ergebnis
- [x] Die kritischen offenen Punkte (authentik-Neuanmeldung mit `auth_time`, Byte-Gleichheit außerhalb des Blocks auf echten Hosts) sind ausdrücklich enthalten
## Abhängigkeiten
#029
## Notizen / Entscheidungen
- Die Abnahme selbst macht der Betreiber; 1.0.0 wird erst danach getaggt.
+32
View File
@@ -0,0 +1,32 @@
# #035 Lokale Benutzerkonten
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** sicherheit, backend, ui
**Geschätzt:** 2 h **Tatsächlich:** 0 h 35 min
## Ziel
sshkv ohne authentik in Betrieb nehmen können – mit eigenen Konten je Person statt eines geteilten Notfall-Admins, damit Rollen und Audit-Log stimmen. Später laufen lokale Konten und OIDC nebeneinander, oder die lokalen werden abgeschaltet.
## Aufgaben
- [x] Anmeldung mit Anmeldename, Passwort und Pflicht-TOTP für lokale Konten (gleicher Ablauf wie der Notfall-Admin)
- [x] Einmal-Passwort vom System, Pflichtwechsel bei der ersten Anmeldung (`passwortWechselNoetig`, Seite `/anmelden/passwort`)
- [x] Benutzerverwaltung `/einstellungen/benutzer`: anlegen, Rolle ändern, sperren/entsperren, zurücksetzen – Recht `benutzerVerwalten` (Admin + frische Bestätigung)
- [x] OIDC-Konten nur sperrbar; Notfall-Admin und eigenes Konto unveränderlich
- [x] Schalter `SSHKV_LOKALE_KONTEN` (Standard an), auch in `docker-compose.yml`
- [x] Protokoll für alle Schritte, nie mit Passwort
- [x] Doku: Handbuch, Installation („Ohne authentik starten“), Sicherheit, Abnahme-Liste
## Akzeptanzkriterien
- [x] DB-Tests: erste Anmeldung (Einmal-Passwort → eigenes → TOTP → angemeldet), schwache/unveränderte Passwörter, doppelte Namen, Rollen- und Sperrregeln, Zurücksetzen, abgeschaltet
- [x] E2E: Admin legt an, Person meldet sich zum ersten Mal an, Operator kommt nicht in die Benutzerverwaltung, Sperren beendet die Sitzung sofort
## Abhängigkeiten
#004
## Notizen / Entscheidungen
- **TOTP ist Pflicht** auch für lokale Konten (Entscheidung Florian, 12.10.2026).
- **Dauerhaft, aber abschaltbar** per `SSHKV_LOKALE_KONTEN=0` (Entscheidung Florian, 12.10.2026).
- **Admins wählen keine Passwörter für andere:** sshkv erzeugt ein Einmal-Passwort (4 × 5 Zeichen ohne verwechselbare Zeichen, gut 115 Bit), das genau einmal angezeigt wird.
- **12 Zeichen Mindestlänge** für eigene Passwörter – mit Pflicht-TOTP ausreichend; der Notfall-Admin behält 16.
- **Sperren beendet Sitzungen sofort** (`sitzungenGueltigAb`); eine Rollenänderung verwirft die gespeicherte Bestätigung.
- Lokale Konten bekommen keine E-Mail-Adresse, damit die OIDC-Verknüpfung per E-Mail nie ein lokales Konto übernehmen kann.
@@ -0,0 +1,25 @@
# #036 Rotation des Deployment-Keys abbrechen
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** sicherheit, backend
**Geschätzt:** 0,5 h **Tatsächlich:** 0 h 20 min
## Ziel
Eine begonnene Rotation des Deployment-Keys (#031) zurücknehmen können, etwa wenn ein Host den neuen Key dauerhaft nicht annimmt – bisher blieb nur, Hosts beim Umstellen zu übergehen.
## Aufgaben
- [x] Worker-Schritt `abbrechen`: Nachfolger WIDERRUFEN, Private Key sofort verwerfen, protokollieren
- [x] Nur vor dem Umstellen; danach meldet sich sshkv schon mit dem neuen Key an
- [x] Seite: Knopf „Rotation abbrechen“, danach Karte „Abgebrochene Rotation aufräumen“ mit Dry-Run
- [x] Neue Rotation sofort möglich (partieller Index aus #033)
## Akzeptanzkriterien
- [x] Integrationstest: verteilen → abbrechen → Dry-Run entfernt den neuen Key, alter arbeitet weiter → neue Rotation startet
- [x] E2E: abbrechen und aufräumen im Browser; nimmt auch eine liegengebliebene Rotation mit
## Abhängigkeiten
#031, #033
## Notizen / Entscheidungen
- Gefunden, weil ein fehlgeschlagener E2E-Lauf eine Rotation auf verwaisten Testhosts hängen ließ: Das Umstellen verweigerte sich zu Recht, aber es gab keinen Weg zurück.
- Der verworfene Key bleibt bis zum Dry-Run in den Blöcken stehen (harmlos – sein Private Key existiert nicht mehr); die Aufräum-Karte zeigt das.
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sshkv", "name": "sshkv",
"version": "0.2.0", "version": "0.5.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",
@@ -56,6 +56,7 @@
"jose": "^6.2.12", "jose": "^6.2.12",
"lucide-react": "1.47.0", "lucide-react": "1.47.0",
"next": "16.3.6", "next": "16.3.6",
"nodemailer": "10.0.10",
"openid-client": "^6.8.8", "openid-client": "^6.8.8",
"pg": "^8.23.1", "pg": "^8.23.1",
"pg-boss": "^12.37.1", "pg-boss": "^12.37.1",
@@ -71,6 +72,7 @@
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@tailwindcss/postcss": "^4.3.3", "@tailwindcss/postcss": "^4.3.3",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"@types/nodemailer": "8.0.2",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.3.0", "@types/react": "^19.3.0",
+19
View File
@@ -199,6 +199,9 @@ importers:
next: next:
specifier: 16.3.6 specifier: 16.3.6
version: 16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) version: 16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
nodemailer:
specifier: 10.0.10
version: 10.0.10
openid-client: openid-client:
specifier: ^6.8.8 specifier: ^6.8.8
version: 6.8.8 version: 6.8.8
@@ -239,6 +242,9 @@ importers:
'@types/node': '@types/node':
specifier: ^24.19.2 specifier: ^24.19.2
version: 24.19.2 version: 24.19.2
'@types/nodemailer':
specifier: 8.0.2
version: 8.0.2
'@types/pg': '@types/pg':
specifier: ^8.23.1 specifier: ^8.23.1
version: 8.23.1 version: 8.23.1
@@ -1481,6 +1487,9 @@ packages:
'@types/node@26.6.5': '@types/node@26.6.5':
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==} resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
'@types/nodemailer@8.0.2':
resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==}
'@types/pg@8.23.1': '@types/pg@8.23.1':
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
@@ -3067,6 +3076,10 @@ packages:
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
engines: {node: '>=18'} engines: {node: '>=18'}
nodemailer@10.0.10:
resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==}
engines: {node: '>=20.0.0'}
non-error@0.1.0: non-error@0.1.0:
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -4910,6 +4923,10 @@ snapshots:
dependencies: dependencies:
undici-types: 8.9.0 undici-types: 8.9.0
'@types/nodemailer@8.0.2':
dependencies:
'@types/node': 24.19.2
'@types/pg@8.23.1': '@types/pg@8.23.1':
dependencies: dependencies:
'@types/node': 26.6.5 '@types/node': 26.6.5
@@ -6625,6 +6642,8 @@ snapshots:
node-releases@2.0.58: {} node-releases@2.0.58: {}
nodemailer@10.0.10: {}
non-error@0.1.0: {} non-error@0.1.0: {}
oauth4webapi@3.8.8: {} oauth4webapi@3.8.8: {}
@@ -0,0 +1,10 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ANLEGEN';
ALTER TYPE "VorgangsArt" ADD VALUE 'ZIELKONTO_ENTFERNEN';
@@ -0,0 +1,3 @@
-- #024: Zwischenzustand „Widerruf eingeleitet“. Der Schluessel bleibt AKTIV (bzw. ABGELAUFEN),
-- bis alle Verwendungen nachweislich entfernt sind; erst dann wird er WIDERRUFEN bzw. ROTIERT.
ALTER TABLE "Schluessel" ADD COLUMN "widerrufEingeleitetAm" TIMESTAMP(3);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "PublicVerwendung" ADD COLUMN "ausserhalbEntfernen" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,36 @@
-- #028: Benachrichtigungen. Versandprotokoll gegen Doppelmeldungen und eine Vorgangsart fuer
-- die Testnachricht (Versand laeuft im Worker).
-- CreateEnum
CREATE TYPE "MeldungsKanal" AS ENUM ('EMAIL', 'WEBHOOK');
-- CreateEnum
CREATE TYPE "ZustellErgebnis" AS ENUM ('GESENDET', 'FEHLGESCHLAGEN');
-- AlterEnum
ALTER TYPE "VorgangsArt" ADD VALUE 'BENACHRICHTIGUNG_TEST';
-- CreateTable
CREATE TABLE "Zustellung" (
"id" TEXT NOT NULL,
"anlass" TEXT NOT NULL,
"kanal" "MeldungsKanal" NOT NULL,
"ergebnis" "ZustellErgebnis" NOT NULL,
"fehler" TEXT,
"versuche" INTEGER NOT NULL DEFAULT 1,
"naechsterVersuchAm" TIMESTAMP(3),
"sendung" TEXT NOT NULL,
"betreff" TEXT NOT NULL,
"zeitpunkt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Zustellung_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Zustellung_zeitpunkt_idx" ON "Zustellung"("zeitpunkt");
-- CreateIndex
CREATE INDEX "Zustellung_sendung_idx" ON "Zustellung"("sendung");
-- CreateIndex
CREATE UNIQUE INDEX "Zustellung_anlass_kanal_key" ON "Zustellung"("anlass", "kanal");
@@ -0,0 +1,15 @@
-- #032: Protokoll-IDs ohne Zweierschritte.
--
-- Bisher zog der Spalten-Default (BIGSERIAL) eine Nummer aus der Sequenz und der Trigger
-- `protokoll_verketten` gleich noch eine – jede Zeile verbrauchte zwei IDs. Die ID muss aber
-- im Trigger unter der Advisory-Sperre vergeben werden: Eine vorher gezogene Nummer koennte
-- kleiner sein als die einer parallelen Transaktion, die zuerst verkettet, und die Kette liesse
-- sich in ID-Reihenfolge nicht mehr pruefen. Deshalb verzichtet der Default auf die Sequenz.
--
-- 0 ist nur ein Platzhalter, damit `prisma.protokoll.create` ohne ID auskommt; der Trigger
-- ueberschreibt jede ID (auch eine mitgegebene). Die Sequenz bleibt der Spalte zugeordnet
-- (OWNED BY), deshalb findet `pg_get_serial_sequence` sie im Trigger weiterhin.
--
-- Bestehende Zeilen bleiben, wie sie sind: Die ID ist Teil der Hash-Eingabe, Umnummerieren
-- wuerde die Kette brechen. Die alten Luecken bleiben also stehen.
ALTER TABLE "Protokoll" ALTER COLUMN "id" SET DEFAULT 0;
@@ -0,0 +1,11 @@
-- Nach einer abgebrochenen Rotation darf derselbe Schluessel erneut rotiert werden (#033).
-- Die Kette bleibt eindeutig: je Vorgaenger hoechstens ein Nachfolger, der nicht widerrufen
-- ist oder gerade widerrufen wird.
DROP INDEX "Schluessel_vorgaengerId_key";
CREATE INDEX "Schluessel_vorgaengerId_idx" ON "Schluessel"("vorgaengerId");
CREATE UNIQUE INDEX "schluessel_ein_offener_nachfolger" ON "Schluessel" ("vorgaengerId")
WHERE "vorgaengerId" IS NOT NULL
AND "widerrufEingeleitetAm" IS NULL
AND "status" <> 'WIDERRUFEN';
@@ -0,0 +1,2 @@
-- Lokale Konten (#035): ein vom Admin vergebenes Einmal-Passwort muss zuerst ersetzt werden.
ALTER TABLE "Benutzer" ADD COLUMN "passwortWechselNoetig" BOOLEAN NOT NULL DEFAULT false;
+107 -51
View File
@@ -110,6 +110,12 @@ enum VorgangsArt {
SCHLUESSEL_IMPORTIEREN SCHLUESSEL_IMPORTIEREN
/// Gespeicherten Private Key endgueltig verwerfen (#014). /// Gespeicherten Private Key endgueltig verwerfen (#014).
PRIVATE_KEY_LOESCHEN PRIVATE_KEY_LOESCHEN
/// Zielbenutzer anlegen und sudoers setzen (#018).
ZIELKONTO_ANLEGEN
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
ZIELKONTO_ENTFERNEN
/// Testnachricht ueber einen Benachrichtigungskanal (#028).
BENACHRICHTIGUNG_TEST
} }
enum VorgangsStatus { enum VorgangsStatus {
@@ -121,6 +127,17 @@ enum VorgangsStatus {
ABGEBROCHEN ABGEBROCHEN
} }
/// Versandweg einer Benachrichtigung (#028).
enum MeldungsKanal {
EMAIL
WEBHOOK
}
enum ZustellErgebnis {
GESENDET
FEHLGESCHLAGEN
}
enum ProtokollErgebnis { enum ProtokollErgebnis {
ERFOLG ERFOLG
FEHLER FEHLER
@@ -131,33 +148,35 @@ enum ProtokollErgebnis {
/// Ein Konto der Oberflaeche. Rolle und Aktiv-Status werden pro Anfrage hier gelesen. /// Ein Konto der Oberflaeche. Rolle und Aktiv-Status werden pro Anfrage hier gelesen.
model Benutzer { model Benutzer {
id String @id @default(cuid()) id String @id @default(cuid())
/// `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 /// 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). /// Self-Service das Konto per E-Mail mit einer vorhandenen Person (#013).
emailBestaetigt Boolean @default(false) emailBestaetigt Boolean @default(false)
/// Anmeldename des Notfall-Admins. /// Anmeldename lokaler Konten: Notfall-Admin und von Admins angelegte Konten (#035).
anmeldename String? @unique anmeldename String? @unique
name String name String
rolle Rolle @default(LESER) rolle Rolle @default(LESER)
aktiv Boolean @default(true) aktiv Boolean @default(true)
istNotfall Boolean @default(false) istNotfall Boolean @default(false)
passwortHash String? passwortHash String?
/// Einmal-Passwort vom Admin: vor allem anderen muss ein eigenes gesetzt werden (#035).
passwortWechselNoetig Boolean @default(false)
/// TOTP-Geheimnis, verschluesselt mit dem aus SSHKV_APP_SECRET abgeleiteten Schluessel /// TOTP-Geheimnis, verschluesselt mit dem aus SSHKV_APP_SECRET abgeleiteten Schluessel
/// (nicht mit dem Master-Key: der liegt nur im Worker, geprueft wird aber im Web). /// (nicht mit dem Master-Key: der liegt nur im Worker, geprueft wird aber im Web).
totpGeheimnis Bytes? totpGeheimnis Bytes?
totpEingerichtetAm DateTime? totpEingerichtetAm DateTime?
/// Letzter angenommener TOTP-Zeitschritt – derselbe Code gilt nur einmal. /// Letzter angenommener TOTP-Zeitschritt – derselbe Code gilt nur einmal.
totpLetzterSchritt BigInt? totpLetzterSchritt BigInt?
/// Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, sind ungueltig. /// Sitzungen, die vor diesem Zeitpunkt ausgestellt wurden, sind ungueltig.
sitzungenGueltigAb DateTime @default(now()) sitzungenGueltigAb DateTime @default(now())
/// Zeitpunkt der letzten erneuten Bestaetigung (Re-Auth) – gilt 5 Minuten. /// Zeitpunkt der letzten erneuten Bestaetigung (Re-Auth) – gilt 5 Minuten.
bestaetigtAm DateTime? bestaetigtAm DateTime?
letzteAnmeldungAm DateTime? letzteAnmeldungAm DateTime?
angelegtAm DateTime @default(now()) angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
person Person? person Person?
angeforderteVorgaenge Vorgang[] angeforderteVorgaenge Vorgang[]
@@ -270,50 +289,58 @@ model HostSchluessel {
// ---------------------------------------------------------------- Schluessel // ---------------------------------------------------------------- Schluessel
model Schluessel { model Schluessel {
id String @id @default(cuid()) id String @id @default(cuid())
/// Zweck, z. B. „Backup srv-backup01 → srv-pve01“. /// Zweck, z. B. „Backup srv-backup01 → srv-pve01“.
name String name String
/// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01. /// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01.
slug String @unique slug String @unique
typ SchluesselTyp typ SchluesselTyp
kategorie SchluesselKategorie kategorie SchluesselKategorie
status SchluesselStatus @default(AKTIV) status SchluesselStatus @default(AKTIV)
/// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar". /// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar".
publicKey String publicKey String
fingerprint String @unique fingerprint String @unique
kommentar String kommentar String
/// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts. /// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts.
privatVerschluesselt Bytes? privatVerschluesselt Bytes?
/// Data-Key, verschluesselt mit dem Master-Key. /// Data-Key, verschluesselt mit dem Master-Key.
dekVerschluesselt Bytes? dekVerschluesselt Bytes?
masterKeyVersion Int? masterKeyVersion Int?
privatGeloeschtAm DateTime? privatGeloeschtAm DateTime?
passphraseGeschuetzt Boolean @default(false) passphraseGeschuetzt Boolean @default(false)
/// Genau ein Deployment-Key ist Instanz-Standard (partieller Index). /// Genau ein Deployment-Key ist Instanz-Standard (partieller Index).
instanzStandard Boolean @default(false) instanzStandard Boolean @default(false)
personId String? personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull) person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
vorlageId String? vorlageId String?
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull) vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
tags String[] tags String[]
ablaufAm DateTime? ablaufAm DateTime?
zuletztVerwendetAm DateTime? zuletztVerwendetAm DateTime?
/// Rotationskette: der Schluessel, den dieser abgeloest hat. /// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
vorgaengerId String? @unique /// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull) widerrufEingeleitetAm DateTime?
nachfolger Schluessel? @relation("Rotation") /// Rotationskette: der Schluessel, den dieser abgeloest hat. Nach einer abgebrochenen Rotation
erstelltVonId String? /// darf ein weiterer Nachfolger folgen; je Vorgaenger hoechstens ein nicht widerrufener
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull) /// (partieller Index, #033).
vorgaengerId String?
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
/// Alle Nachfolger, auch abgebrochene. Massgeblich ist der juengste (`nachfolgerVon`).
nachfolgerListe Schluessel[] @relation("Rotation")
erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service). /// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
selbstHinterlegt Boolean @default(false) selbstHinterlegt Boolean @default(false)
angelegtAm DateTime @default(now()) angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
publicVerwendungen PublicVerwendung[] publicVerwendungen PublicVerwendung[]
privateVerwendungen PrivateVerwendung[] privateVerwendungen PrivateVerwendung[]
hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel") hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel")
gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel") gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel")
vorgaenge Vorgang[] vorgaenge Vorgang[]
@@index([vorgaengerId])
} }
/// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe. /// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe.
@@ -329,6 +356,9 @@ model PublicVerwendung {
zielbenutzer String zielbenutzer String
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12". /// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
optionen String? optionen String?
/// Beim Ausrollen dieselbe Zeile ausserhalb des Blocks entfernen – nur fuer uebernommene
/// Keys (#022), sonst fasst sshkv fremde Zeilen nie an.
ausserhalbEntfernen Boolean @default(false)
zustand VerwendungsZustand @default(GEPLANT) zustand VerwendungsZustand @default(GEPLANT)
zuletztAngewendetAm DateTime? zuletztAngewendetAm DateTime?
angelegtVonId String? angelegtVonId String?
@@ -460,7 +490,9 @@ model Vorgang {
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile /// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet. /// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
model Protokoll { model Protokoll {
id BigInt @id @default(autoincrement()) /// Vergibt der Trigger `protokoll_verketten` unter der Advisory-Sperre aus der Sequenz; der
/// Default 0 ist nur ein Platzhalter, damit `create` ohne ID auskommt (#032).
id BigInt @id @default(0)
zeitpunkt DateTime @default(now()) zeitpunkt DateTime @default(now())
akteurId String? akteurId String?
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird. /// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
@@ -486,6 +518,30 @@ model Einstellung {
geaendertAm DateTime @updatedAt geaendertAm DateTime @updatedAt
} }
/// Versandprotokoll der Benachrichtigungen (#028): je Anlass und Kanal hoechstens eine Zeile.
/// Vor jedem Versand wird hiergegen geprueft – so meldet sshkv dieselbe Abweichung oder dieselbe
/// Ablaufstufe nie zweimal, auch wenn ein Zeitplan doppelt feuert oder der Worker neu startet.
model Zustellung {
id String @id @default(cuid())
/// Fingerabdruck des Anlasses, z. B. drift:<abweichungId>, ablauf:<schluesselId>:7:<datum>.
anlass String
kanal MeldungsKanal
ergebnis ZustellErgebnis
/// Kurzer Fehlertext ohne Zugangsdaten.
fehler String?
versuche Int @default(1)
/// Wann ein Fehlschlag fruehestens erneut versucht wird; leer = nicht mehr.
naechsterVersuchAm DateTime?
/// Gemeinsame Kennung aller Anlaesse einer Nachricht – die Oberflaeche zeigt je Nachricht eine Zeile.
sendung String
betreff String
zeitpunkt DateTime @default(now())
@@unique([anlass, kanal])
@@index([zeitpunkt])
@@index([sendung])
}
/// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster). /// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster).
model Ratenbegrenzung { model Ratenbegrenzung {
schluessel String @id schluessel String @id
+284
View File
@@ -0,0 +1,284 @@
/**
* Überträgt die Issue-Dateien aus `docs/issues/` in den Gitea-Issue-Tracker.
*
* GITEA_URL=https://git.wilde-server.de \
* GITEA_REPO=florian.wilde/sshkv \
* GITEA_TOKEN=... \
* npm run issues:sync
*
* Ohne Token oder mit `--dry-run` läuft nur ein Trockenlauf: Es wird ausgegeben, was passieren würde.
* Die Markdown-Dateien bleiben die Quelle der Wahrheit; Gitea erhält eine Kopie zum Kommentieren.
*/
import { execFileSync } from "node:child_process";
import { readFile, readdir } from "node:fs/promises";
import "dotenv/config";
import path from "node:path";
interface IssueDatei {
nummer: number;
titel: string;
status: string;
meilenstein: string | null;
labels: string[];
koerper: string;
dateiname: string;
}
/** Farben der Labels, angelehnt an die Design-Tokens (#030). */
const LABEL_FARBEN: Record<string, string> = {
backend: "#6aa8ff",
ui: "#f0b43c",
datenbank: "#3cc9a7",
sicherheit: "#f2714b",
test: "#3cc9a7",
docs: "#8b96a3",
infrastruktur: "#2a323b",
chore: "#8b96a3",
};
function feld(inhalt: string, name: string): string | null {
const treffer = new RegExp(`^\\*\\*${name}:\\*\\*\\s*(.+)$`, "m").exec(inhalt);
return treffer ? (treffer[1] ?? "").trim() : null;
}
async function dateienLesen(verzeichnis: string): Promise<IssueDatei[]> {
const namen = (await readdir(verzeichnis)).filter((name) => /^\d{3}-.*\.md$/.test(name)).sort();
const issues: IssueDatei[] = [];
for (const dateiname of namen) {
const inhalt = await readFile(path.join(verzeichnis, dateiname), "utf8");
const kopf = /^#\s*#(\d+)\s+(.+)$/m.exec(inhalt);
if (!kopf) {
console.warn(`Übersprungen (keine Überschrift „# #NNN Titel“): ${dateiname}`);
continue;
}
// Der Körper beginnt nach den Kopfzeilen (Status, Meilenstein, Labels, Zeiten).
const körperStart = inhalt.indexOf("## Ziel");
issues.push({
nummer: Number(kopf[1]),
titel: (kopf[2] ?? "").trim(),
status: feld(inhalt, "Status") ?? "offen",
meilenstein: feld(inhalt, "Meilenstein"),
labels: (feld(inhalt, "Labels") ?? "")
.split(",")
.map((label) => label.trim().toLowerCase())
.filter(Boolean),
koerper: körperStart >= 0 ? inhalt.slice(körperStart).trim() : inhalt,
dateiname,
});
}
return issues;
}
class Gitea {
constructor(
private readonly basis: string,
private readonly repo: string,
/** Fertiger Wert fuer den Authorization-Header (`token …` oder `Basic …`). */
private readonly autorisierung: string,
) {}
private async anfrage<T>(pfad: string, optionen: RequestInit = {}): Promise<T> {
const antwort = await fetch(`${this.basis}/api/v1/repos/${this.repo}${pfad}`, {
...optionen,
headers: {
Authorization: this.autorisierung,
"Content-Type": "application/json",
...optionen.headers,
},
});
if (!antwort.ok) {
throw new Error(`Gitea antwortet mit ${antwort.status} auf ${pfad}: ${await antwort.text()}`);
}
return (await antwort.json()) as T;
}
labels() {
return this.anfrage<{ id: number; name: string }[]>("/labels?limit=100");
}
labelAnlegen(name: string) {
return this.anfrage<{ id: number; name: string }>("/labels", {
method: "POST",
body: JSON.stringify({ name, color: LABEL_FARBEN[name] ?? "#6c757d" }),
});
}
meilensteine() {
return this.anfrage<{ id: number; title: string }[]>("/milestones?state=all&limit=100");
}
meilensteinAnlegen(title: string) {
return this.anfrage<{ id: number; title: string }>("/milestones", {
method: "POST",
body: JSON.stringify({ title }),
});
}
issues() {
return this.anfrage<{ number: number; title: string; state: string }[]>("/issues?state=all&limit=200");
}
issueAnlegen(daten: Record<string, unknown>) {
return this.anfrage<{ number: number }>("/issues", { method: "POST", body: JSON.stringify(daten) });
}
issueAendern(nummer: number, daten: Record<string, unknown>) {
return this.anfrage<{ number: number }>(`/issues/${nummer}`, {
method: "PATCH",
body: JSON.stringify(daten),
});
}
}
/**
* Ermittelt Serveradresse und Repo aus dem Git-Remote, damit beides nicht doppelt gepflegt werden muss.
* Beispiel: https://git.example.de/benutzer/sshkv.git → { basis, repo }
*/
function ausRemote(): { basis: string; repo: string } | null {
try {
const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim();
const treffer = /^https?:\/\/(?:[^@/]*@)?([^/]+)\/(.+?)(?:\.git)?$/.exec(url);
if (!treffer) return null;
return { basis: `https://${treffer[1]}`, repo: treffer[2] ?? "" };
} catch {
return null;
}
}
/**
* Holt die Zugangsdaten aus Git (Schlüsselbund oder `~/.git-credentials`), damit sie nicht
* zusätzlich in der .env stehen müssen. Hinterlegt ist dort ein API-Token oder – wie bei
* sshkeyvault – das Konto-Passwort; Gitea nimmt beides an, das Passwort aber nur per Basic-Auth
* (#030).
*/
function zugangAusGitCredentials(basis: string): { benutzer: string; geheimnis: string } | null {
const host = new URL(basis).host;
// Erst die eingerichteten Helfer (z. B. Schlüsselbund), dann zusätzlich die Datei
// ~/.git-credentials – je nach Rechner ist mal das eine, mal das andere gefüllt.
for (const zusatz of [[], ["-c", "credential.helper=store"]]) {
try {
const antwort = execFileSync("git", [...zusatz, "credential", "fill"], {
input: `protocol=https\nhost=${host}\n\n`,
encoding: "utf8",
// Keine Rückfrage im Terminal: Wenn nichts hinterlegt ist, soll der Aufruf scheitern.
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
stdio: ["pipe", "pipe", "ignore"],
});
const passwort = /^password=(.*)$/m.exec(antwort);
const benutzer = /^username=(.*)$/m.exec(antwort);
if (passwort?.[1]) return { benutzer: benutzer?.[1] ?? "", geheimnis: passwort[1] };
} catch {
// Nächste Möglichkeit versuchen.
}
}
return null;
}
/** Titel in Gitea, z. B. „#004 Agent-Skript“ – daran werden vorhandene Issues wiedererkannt. */
function giteaTitel(issue: IssueDatei): string {
return `#${String(issue.nummer).padStart(3, "0")} ${issue.titel}`;
}
async function main() {
const trockenlauf = process.argv.includes("--dry-run");
const remote = ausRemote();
const basis = (process.env.GITEA_URL ?? remote?.basis ?? "").replace(/\/$/, "");
const repo = process.env.GITEA_REPO ?? remote?.repo ?? "";
const zugang = process.env.GITEA_TOKEN ? null : basis ? zugangAusGitCredentials(basis) : null;
const token = process.env.GITEA_TOKEN || zugang?.geheimnis || "";
const tokenQuelle = process.env.GITEA_TOKEN ? "Umgebungsvariable" : "Git-Zugangsdaten";
// Ein Gitea-Token sind 40 Hex-Zeichen; alles andere ist ein Passwort und geht per Basic-Auth.
const autorisierung =
process.env.GITEA_TOKEN || /^[0-9a-f]{40}$/.test(token) || !zugang?.benutzer
? `token ${token}`
: `Basic ${Buffer.from(`${zugang.benutzer}:${token}`).toString("base64")}`;
const verzeichnis = path.join(process.cwd(), "docs", "issues");
const issues = await dateienLesen(verzeichnis);
console.log(`${issues.length} Issue-Dateien gelesen.`);
if (trockenlauf || !basis || !repo || !token) {
if (!trockenlauf) {
console.log(
"\nAdresse, Repo oder Token nicht ermittelbar – es läuft nur ein Trockenlauf.\n" +
"Entweder GITEA_TOKEN setzen oder einmal `git push` ausführen, damit der Token hinterlegt wird.\n",
);
}
for (const issue of issues) {
console.log(
` ${giteaTitel(issue)} [${issue.status}] Meilenstein ${issue.meilenstein ?? "–"} Labels: ${
issue.labels.join(", ") || "–"
}`,
);
}
console.log(
`\nZiel wäre: ${basis || "?"}/${repo || "?"} – Token ${token ? "vorhanden" : "fehlt"}.` +
"\nDer Token wird aus den Git-Zugangsdaten gelesen; er braucht zusätzlich das Recht „issue:write“.",
);
return;
}
console.log(`Ziel: ${basis}/${repo} (Token aus: ${tokenQuelle})`);
const gitea = new Gitea(basis, repo, autorisierung);
const vorhandeneLabels = new Map((await gitea.labels()).map((l) => [l.name.toLowerCase(), l.id]));
const benoetigteLabels = [...new Set(issues.flatMap((issue) => issue.labels))];
for (const name of benoetigteLabels) {
if (!vorhandeneLabels.has(name)) {
const neu = await gitea.labelAnlegen(name);
vorhandeneLabels.set(name, neu.id);
console.log(`Label angelegt: ${name}`);
}
}
const vorhandeneMeilensteine = new Map((await gitea.meilensteine()).map((m) => [m.title, m.id]));
const benoetigteMeilensteine = [
...new Set(issues.map((issue) => issue.meilenstein).filter((m): m is string => Boolean(m))),
].sort();
for (const titel of benoetigteMeilensteine) {
if (!vorhandeneMeilensteine.has(titel)) {
const neu = await gitea.meilensteinAnlegen(titel);
vorhandeneMeilensteine.set(titel, neu.id);
console.log(`Meilenstein angelegt: ${titel}`);
}
}
const vorhandeneIssues = new Map((await gitea.issues()).map((i) => [i.title, i]));
for (const issue of issues) {
const titel = giteaTitel(issue);
const daten = {
title: titel,
body: `${issue.koerper}\n\n---\nQuelle: \`docs/issues/${issue.dateiname}\` – dort wird dieses Issue gepflegt.`,
labels: issue.labels
.map((name) => vorhandeneLabels.get(name))
.filter((id): id is number => Boolean(id)),
milestone: issue.meilenstein ? vorhandeneMeilensteine.get(issue.meilenstein) : undefined,
};
const vorhanden = vorhandeneIssues.get(titel);
const zustand = issue.status.startsWith("erledigt") ? "closed" : "open";
if (vorhanden) {
await gitea.issueAendern(vorhanden.number, { ...daten, state: zustand });
console.log(`Aktualisiert: ${titel} (${zustand})`);
} else {
const neu = await gitea.issueAnlegen(daten);
if (zustand === "closed") await gitea.issueAendern(neu.number, { state: "closed" });
console.log(`Angelegt: ${titel} (${zustand})`);
}
}
console.log("\nAbgleich abgeschlossen.");
}
main().catch((fehler) => {
console.error(fehler instanceof Error ? fehler.message : fehler);
process.exit(1);
});
+14
View File
@@ -39,6 +39,20 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
{ name: "meine-schluessel", pfad: "/meine-schluessel" }, { name: "meine-schluessel", pfad: "/meine-schluessel" },
{ name: "hosts", pfad: "/hosts" }, { name: "hosts", pfad: "/hosts" },
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' }, { name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
// Matrix Schluessel × Host (#025): die Seite darf auf 390 px nicht seitwaerts scrollen –
// nur der Matrix-Container. „belegt=1“, damit das Bild Zellen zeigt statt leerer Zeilen.
{ name: "zuweisungen", pfad: "/zuweisungen?belegt=1" },
{ name: "vorlagen", pfad: "/vorlagen" },
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
{
name: "vorlage-bearbeiten",
pfad: "/vorlagen",
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
},
// Audit-Log (#027): unter md Karten statt Tabelle, die Seite darf nicht seitwaerts scrollen.
{ name: "protokoll", pfad: "/protokoll" },
// Benachrichtigungen, Zeitplaene, Instanz (#028) – als Admin mit Formular und Testknoepfen.
{ name: "einstellungen", pfad: "/einstellungen" },
]; ];
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>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { CheckCheck, KeyRound, ShieldCheck, Undo2 } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Umstellen } from "@/components/deploymentkey/Umstellen";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Karte } from "@/components/ui/Karte";
import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { rotationsSchrittAktion } from "@/server/actions/deploymentkey";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { rotationsStand } from "@/server/services/deploymentkey";
export const metadata: Metadata = { title: "Deployment-Key rotieren" };
const SCHRITTE = ["Erzeugen", "Verteilen", "Prüfen", "Umstellen", "Aufräumen"] as const;
/**
* Rotation des Instanz-Deployment-Keys (#031). Der neue Key kommt erst neben den alten,
* wird geprueft und erst dann zum Standard; der alte verschwindet zuletzt.
*/
export default async function DeploymentKeySeite() {
const { benutzer } = await sitzungErforderlich("deploymentKeyVerwalten", "/deployment-key");
const stand = await rotationsStand();
const fehlgeschlagen = (stand.pruefung ?? []).filter((p) => !p.ok);
const aktuell =
stand.phase === "bereit"
? 0
: stand.phase === "aufraeumen"
? 4
: !stand.pruefung
? 1
: fehlgeschlagen.length
? 2
: 3;
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={
<Breadcrumb
pfad={[{ titel: "Einstellungen", href: "/einstellungen" }, { titel: "Deployment-Key" }]}
/>
}
titel="Deployment-Key rotieren"
untertitel="Ohne Unterbrechung: der neue Key steht erst neben dem alten, wird geprüft und löst ihn dann ab."
/>
<Schrittleiste schritte={SCHRITTE} aktuell={aktuell} beschriftung="Schritte der Rotation" />
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
<p className="text-accent text-[13.5px]">
Jeder Schritt verlangt eine frische Bestätigung Ihrer Anmeldung.{" "}
<Link href="/bestaetigen?ziel=%2Fdeployment-key" className="underline">
Jetzt bestätigen
</Link>
</p>
)}
<Karte titel="Aktueller Deployment-Key" untertitel={`Benutzt auf ${stand.hosts.length} Host(s)`}>
{stand.aktuell ? (
<MonoKopieren
wert={stand.aktuell.fingerprint}
beschriftung="Fingerprint des aktuellen Keys"
umbrechen
/>
) : (
<p className="text-accent text-[14px]">Noch kein Deployment-Key – läuft der Worker?</p>
)}
{stand.nachfolger && (
<div className="mt-4">
<p className="text-muted mb-1 text-[13px]">Neuer Key (noch nicht Standard)</p>
<MonoKopieren
wert={stand.nachfolger.fingerprint}
beschriftung="Fingerprint des neuen Keys"
umbrechen
/>
</div>
)}
</Karte>
{stand.phase === "bereit" && stand.abgebrochen && (
<Karte
titel="Abgebrochene Rotation aufräumen"
untertitel="Der verworfene Key kann noch in den Blöcken stehen. Ein Dry-Run zeigt es; steht alles auf Soll, ist nichts mehr zu tun."
>
<p className="text-muted mb-4 font-mono text-[13px] break-all">{stand.abgebrochen.fingerprint}</p>
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={0} />
</Karte>
)}
{stand.phase === "bereit" && stand.aktuell && (
<Karte
titel="1 · Neuen Key erzeugen"
untertitel="Der Worker erzeugt ein neues ed25519-Paar. Noch ändert sich auf keinem Host etwas."
>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "erzeugen")}
beschriftung="Neuen Deployment-Key erzeugen"
variante="primaer"
icon={<KeyRound className="size-4" />}
/>
</Karte>
)}
{stand.phase === "verteilen" && (
<>
<Karte
titel="2 · Neuen Key verteilen"
untertitel="Der neue Key kommt zusätzlich in den verwalteten Block – der alte bleibt vorerst."
>
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={stand.hosts.length} />
</Karte>
<Karte
titel="3 · Anmeldung mit dem neuen Key prüfen"
untertitel="sshkv meldet sich auf jedem Host einmal mit dem neuen Key an."
>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "pruefen")}
beschriftung="Neuen Key prüfen"
icon={<ShieldCheck className="size-4" />}
/>
{stand.pruefung && (
<ul className="mt-4 flex flex-col gap-1.5" aria-label="Ergebnis der Prüfung">
{stand.pruefung.map((p) => (
<li key={p.hostId} className="flex flex-wrap items-center gap-2 text-[14px]">
<span className="font-mono">{p.hostName}</span>
{p.ok ? (
<Badge variante="ok">angemeldet</Badge>
) : (
<Badge variante="gefahr">fehlgeschlagen</Badge>
)}
{p.fehler && <span className="text-muted text-[13px]">{p.fehler}</span>}
</li>
))}
</ul>
)}
</Karte>
{stand.pruefung && (
<Karte
titel="4 · Umstellen"
untertitel="Ab jetzt benutzt sshkv den neuen Key; der alte gilt als rotiert."
>
<Umstellen fehlgeschlagen={fehlgeschlagen} />
</Karte>
)}
<Karte
titel="Rotation abbrechen"
untertitel="Solange nicht umgestellt ist: der neue Key wird verworfen, der alte bleibt unverändert. Danach per Dry-Run den neuen wieder von den Hosts entfernen."
className="border-danger-border"
>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "abbrechen")}
beschriftung="Rotation abbrechen"
variante="gefahr"
icon={<Undo2 className="size-4" />}
/>
</Karte>
</>
)}
{stand.phase === "aufraeumen" && stand.vorgaenger && (
<Karte
titel="5 · Alten Key entfernen"
untertitel="Dry-Run und Ausrollen entfernen den alten Key aus allen Blöcken; danach wird sein Private Key verworfen."
>
<div className="flex flex-col gap-5">
<p className="text-muted text-[13px]">
Alter Key: <span className="font-mono">{stand.vorgaenger.fingerprint}</span>
</p>
<AusrollenPanel auswahl={{ deploymentKey: true }} offen={stand.hosts.length} />
<div>
<VorgangsKnopf
aktion={rotationsSchrittAktion.bind(null, "abschliessen")}
beschriftung="Rotation abschließen"
variante="primaer"
icon={<CheckCheck className="size-4" />}
/>
</div>
</div>
</Karte>
)}
</div>
);
}
@@ -0,0 +1,101 @@
import type { Metadata } from "next";
import Link from "next/link";
import { KontoAktionen } from "@/components/benutzer/KontoAktionen";
import { KontoAnlegen } from "@/components/benutzer/KontoAnlegen";
import { Badge, Breadcrumb, Karte, Seitenkopf, Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui";
import { datumZeit } from "@/lib/format";
import { lokaleKontenAktiv } from "@/lib/lokale-konten";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { benutzerListe, type KontoHerkunft } from "@/server/queries/benutzer";
export const metadata: Metadata = { title: "Benutzer" };
const ROLLE: Record<string, string> = { ADMIN: "Admin", OPERATOR: "Operator", LESER: "Leser" };
const HERKUNFT: Record<KontoHerkunft, string> = {
notfall: "Notfall-Admin",
lokal: "lokal",
oidc: process.env["SSHKV_OIDC_NAME"] ?? "OIDC",
};
/**
* Benutzerverwaltung (#035): lokale Konten anlegen, Rollen vergeben, sperren, zuruecksetzen.
* OIDC-Konten erscheinen nach ihrer ersten Anmeldung; ihre Rolle kommt aus dem Anbieter.
*/
export default async function BenutzerSeite() {
const { benutzer } = await sitzungErforderlich("benutzerVerwalten", "/einstellungen/benutzer");
const konten = await benutzerListe();
const lokal = lokaleKontenAktiv();
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={
<Breadcrumb pfad={[{ titel: "Einstellungen", href: "/einstellungen" }, { titel: "Benutzer" }]} />
}
titel="Benutzer"
untertitel="Lokale Konten mit Passwort und TOTP – neben oder statt des Identitätsanbieters."
/>
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
<p className="text-accent text-[13.5px]">
Änderungen an Konten verlangen eine frische Bestätigung Ihrer Anmeldung.{" "}
<Link href="/bestaetigen?ziel=%2Feinstellungen%2Fbenutzer" className="underline">
Jetzt bestätigen
</Link>
</p>
)}
{lokal ? (
<Karte
titel="Lokales Konto anlegen"
untertitel="sshkv erzeugt ein Einmal-Passwort; bei der ersten Anmeldung setzt die Person ein eigenes und richtet TOTP ein."
>
<KontoAnlegen />
</Karte>
) : (
<p className="text-muted text-[14px]">
Lokale Konten sind abgeschaltet (<code className="font-mono">SSHKV_LOKALE_KONTEN=0</code>).
Vorhandene lokale Konten können sich nicht anmelden.
</p>
)}
<Karte titel="Konten" untertitel={konten.length === 1 ? "1 Konto" : `${konten.length} Konten`}>
<Tabelle
beschriftung="Konten"
spalten={[
{ titel: "Name" },
{ titel: "Anmeldung" },
{ titel: "Rolle" },
{ titel: "Zuletzt angemeldet", versteckt: true },
{ titel: "Aktionen", rechts: true },
]}
>
{konten.map((k) => (
<TabellenZeile key={k.id}>
<TabellenZelle>
<span className="block font-medium">{k.name}</span>
<span className="text-muted block font-mono text-[12.5px]">
{k.anmeldename ?? k.email ?? "–"}
</span>
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<span className="flex flex-wrap gap-1.5">
<Badge>{HERKUNFT[k.herkunft]}</Badge>
{!k.aktiv && <Badge variante="gefahr">gesperrt</Badge>}
{k.aktiv && k.einrichtungOffen && <Badge variante="messing">Einrichtung offen</Badge>}
</span>
</TabellenZelle>
<TabellenZelle>{ROLLE[k.rolle]}</TabellenZelle>
<TabellenZelle className="text-muted text-[13px] whitespace-nowrap">
{k.letzteAnmeldungAm ? datumZeit(k.letzteAnmeldungAm) : "noch nie"}
</TabellenZelle>
<TabellenZelle rechts>
<KontoAktionen konto={k} selbst={k.id === benutzer.id} />
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
</Karte>
</div>
);
}
+211 -10
View File
@@ -1,18 +1,219 @@
import { Inbox } 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 type { ReactNode } from "react";
import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular";
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 { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { datumZeit } from "@/lib/format";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { einstellungenSeite, type Zeitplan, type ZustellungZeile } from "@/server/queries/einstellungen";
export const metadata: Metadata = { title: "Einstellungen" }; export const metadata: Metadata = { title: "Einstellungen" };
export default async function Einstellungen() { function Info({ titel, children }: { titel: string; children: ReactNode }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
return ( return (
<Platzhalterseite <div className="min-w-0">
titel="Einstellungen" <dt className="text-muted text-[13px] font-medium">{titel}</dt>
untertitel="Deployment-Key, eigene Vorlagen, Benachrichtigungen" <dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
phase={3} </div>
issue="#019" );
/> }
function Abschnitt({ titel, id, children }: { titel: string; id: string; children: ReactNode }) {
return (
<section aria-labelledby={id} className="flex min-w-0 flex-col gap-4">
<h2 id={id} className="text-muted text-[12.5px] font-semibold tracking-[0.08em] uppercase">
{titel}
</h2>
{children}
</section>
);
}
function ZeitplanAnzeige({ plan }: { plan: Zeitplan }) {
return (
<span className="flex flex-col gap-0.5">
{plan.beschreibung && <span>{plan.beschreibung}</span>}
<code className="text-muted font-mono text-[13px]">{plan.ausdruck}</code>
</span>
);
}
const KANAL_TEXT = { EMAIL: "E-Mail", WEBHOOK: "Webhook" } as const;
function Zustellungen({ zeilen }: { zeilen: readonly ZustellungZeile[] }) {
if (zeilen.length === 0) {
return (
<Leerzustand
icon={<Inbox className="size-5" />}
titel="Noch nichts versendet"
text="Hier erscheinen die letzten Zustellungen samt Ergebnis – auch Testnachrichten."
/>
);
}
return (
<Tabelle
beschriftung="Letzte Zustellungen"
spalten={[{ titel: "Zeitpunkt" }, { titel: "Kanal" }, { titel: "Nachricht" }, { titel: "Ergebnis" }]}
>
{zeilen.map((z) => (
<TabellenZeile key={z.sendung}>
<TabellenZelle className="whitespace-nowrap">
<time dateTime={z.zeitpunkt.toISOString()}>{datumZeit(z.zeitpunkt)}</time>
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">{KANAL_TEXT[z.kanal]}</TabellenZelle>
<TabellenZelle className="min-w-[16rem]">
<span className="block">{z.betreff}</span>
<span className="text-muted block text-[12.5px]">
{z.anlaesse === 1 ? "1 Anlass" : `${z.anlaesse} Anlässe`}
{z.versuche > 1 && ` · ${z.versuche}. Versuch`}
</span>
</TabellenZelle>
<TabellenZelle className="min-w-[12rem]">
{z.ergebnis === "GESENDET" ? (
<Badge variante="ok">gesendet</Badge>
) : (
<span className="flex flex-col items-start gap-1">
<Badge variante="gefahr">fehlgeschlagen</Badge>
{z.fehler && <span className="text-danger text-[12.5px] break-words">{z.fehler}</span>}
<span className="text-muted text-[12.5px]">
{z.naechsterVersuchAm
? `neuer Versuch ab ${datumZeit(z.naechsterVersuchAm)}`
: "kein weiterer Versuch"}
</span>
</span>
)}
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
);
}
/** Einstellungen (#028): lesen duerfen alle, aendern nur `einstellungenVerwalten` (Admin). */
export default async function Einstellungen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
const { benutzer } = await sitzungErforderlich("lesen", "/einstellungen");
const darfAendern = darf(benutzer.rolle, "einstellungenVerwalten");
const seite = await einstellungenSeite();
// Eine Webhook-Adresse traegt oft ein Token im Pfad – Leser sehen nur Schema und Host.
const ansicht = darfAendern
? seite.benachrichtigung
: {
...seite.benachrichtigung,
webhook: { ...seite.benachrichtigung.webhook, url: seite.webhookZiel ?? "" },
};
const { instanz } = seite;
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Einstellungen"
untertitel={
darfAendern
? "Benachrichtigungen, Zeitpläne und Angaben zu dieser Instanz"
: "Nur Ansicht – ändern dürfen Administratoren."
}
/>
<div className="grid min-w-0 gap-6 sm:gap-7 xl:grid-cols-[minmax(0,1fr)_22rem] xl:items-start">
<Abschnitt titel="Benachrichtigungen" id="abschnitt-benachrichtigungen">
<BenachrichtigungFormular ansicht={ansicht} darfAendern={darfAendern} />
<Karte
titel="Letzte Zustellungen"
untertitel="Fehlgeschlagene Zustellungen wiederholt der Worker bis zu fünfmal."
>
<Zustellungen zeilen={seite.zustellungen} />
</Karte>
</Abschnitt>
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
<Abschnitt titel="Abgleich" id="abschnitt-abgleich">
<Karte as="div">
<dl className="grid gap-4">
<Info titel="Abgleich aller Hosts">
<ZeitplanAnzeige plan={seite.abgleich} />
</Info>
<Info titel="Tägliche Zusammenfassung">
<ZeitplanAnzeige plan={seite.meldung} />
</Info>
<Info titel="Geänderte Host-Keys">alle 5 Minuten geprüft</Info>
</dl>
<p className="text-muted mt-4 text-[13px]">
Zeitzone Europe/Berlin. Gesetzt über <code className="font-mono">SSHKV_SYNC_CRON</code> und{" "}
<code className="font-mono">SSHKV_MELDUNG_CRON</code> in der Umgebung des Workers.
</p>
</Karte>
</Abschnitt>
{/* Platz fuer den Abschnitt „Syslog“ – folgt in einem eigenen Issue. */}
<Abschnitt titel="Instanz" id="abschnitt-instanz">
<Karte as="div">
<dl className="grid gap-4">
<Info titel="Version">
<span className="font-mono text-[13.5px]">{instanz.version}</span>
</Info>
<Info titel="Deployment-Key">
{instanz.deploymentKey ? (
<>
<MonoKopieren
wert={instanz.deploymentKey.fingerprint}
beschriftung="Fingerprint des Deployment-Keys"
umbrechen
/>
{darf(benutzer.rolle, "deploymentKeyVerwalten") && (
<Link
href="/deployment-key"
className="text-accent mt-1 inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
>
Deployment-Key rotieren
</Link>
)}
</>
) : (
<span className="text-accent">noch nicht erzeugt – läuft der Worker?</span>
)}
</Info>
<Info titel="Anmeldung (OIDC)">
{instanz.oidc.konfiguriert ? (
instanz.oidc.name
) : (
<span className="text-muted">nicht eingerichtet</span>
)}
</Info>
<Info titel="Benutzer">
{darf(benutzer.rolle, "benutzerVerwalten") ? (
<Link
href="/einstellungen/benutzer"
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
>
Konten verwalten
</Link>
) : (
<span className="text-muted">nur für Admins</span>
)}
</Info>
<Info titel="Notfall-Admin">
{!instanz.notfall.vorhanden ? (
<span className="text-muted">nicht angelegt</span>
) : instanz.notfall.totpEingerichtet ? (
<Badge variante="ok">TOTP eingerichtet</Badge>
) : (
<Badge variante="messing">TOTP noch nicht eingerichtet</Badge>
)}
</Info>
</dl>
</Karte>
</Abschnitt>
</div>
</div>
</div>
); );
} }
+13
View File
@@ -18,7 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { hostDetail, type HostDetail } from "@/server/queries/hosts"; import { hostDetail, type HostDetail } from "@/server/queries/hosts";
import { BootstrapAktionen } from "./BootstrapAktionen"; import { BootstrapAktionen } from "./BootstrapAktionen";
import { sshdHinweise } from "@/lib/sshd-hinweise"; import { sshdHinweise } from "@/lib/sshd-hinweise";
import { AbgleichKarten } from "@/components/abgleich/AbgleichKarten";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { istZustand } from "@/server/queries/abgleich";
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand"; import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
import { verwendungenAufHost } from "@/server/queries/ziele"; import { verwendungenAufHost } from "@/server/queries/ziele";
import { HostKeyAktionen } from "./HostKeyAktionen"; import { HostKeyAktionen } from "./HostKeyAktionen";
@@ -100,6 +102,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert; const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert; const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
const verwendungen = await verwendungenAufHost(host.id); const verwendungen = await verwendungenAufHost(host.id);
const ist = await istZustand(host.id);
const jetzt = new Date(); const jetzt = new Date();
return ( return (
@@ -295,6 +298,16 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
)} )}
</Karte> </Karte>
{host.bootstrapAm && (
<AbgleichKarten
hostId={host.id}
zustand={ist}
darfAbgleichen={verwalten}
darfUebernehmen={darf(benutzer.rolle, "schluesselVerwalten") && !archiviert}
darfVerwalten={darfAusrollen}
/>
)}
{verwalten && ( {verwalten && (
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren"> <Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+5 -1
View File
@@ -11,6 +11,10 @@ export async function huellenBenutzerErmitteln(): Promise<HuellenBenutzer> {
name: benutzer.name, name: benutzer.name,
rolle: benutzer.rolle, rolle: benutzer.rolle,
herkunft: herkunft:
sitzung.herkunft === "notfall" ? "Notfallzugang" : (process.env["SSHKV_OIDC_NAME"] ?? "authentik"), sitzung.herkunft === "notfall"
? "Notfallzugang"
: sitzung.herkunft === "lokal"
? "lokales Konto"
: (process.env["SSHKV_OIDC_NAME"] ?? "authentik"),
}; };
} }
+94 -64
View File
@@ -1,15 +1,7 @@
import { ArrowRight, Plus, RefreshCw, Route } from "lucide-react"; import { ArrowRight, Plus, Route } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
abweichungen,
BEISPIEL_JETZT,
baldAblaufend,
kennzahlen,
letzteAktionen,
letzterAbgleich,
type AktionsArt,
} from "@/beispiel/uebersicht";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Karte } from "@/components/ui/Karte"; import { Karte } from "@/components/ui/Karte";
@@ -18,7 +10,10 @@ import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt } from "@/components/ui/Statuspunkt"; import { Statuspunkt } from "@/components/ui/Statuspunkt";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format"; import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { uebersicht, type AktionsArt } from "@/server/queries/uebersicht";
export const metadata: Metadata = { title: "Übersicht" }; export const metadata: Metadata = { title: "Übersicht" };
@@ -28,36 +23,45 @@ const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
erstellt: { text: "Erstellt", farbe: "text-text" }, erstellt: { text: "Erstellt", farbe: "text-text" },
abgleich: { text: "Abgleich", farbe: "text-info" }, abgleich: { text: "Abgleich", farbe: "text-info" },
rotiert: { text: "Rotiert", farbe: "text-accent" }, rotiert: { text: "Rotiert", farbe: "text-accent" },
anmeldung: { text: "Anmeldung", farbe: "text-muted" },
verweigert: { text: "Verweigert", farbe: "text-danger" },
sonstiges: { text: "Aktion", farbe: "text-text" },
}; };
/** /** Übersicht (#026): Aufbau aus dem Prototyp (#002), Zahlen aus der Datenbank. */
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
* folgt in #026; Aufbau und Komponenten bleiben.
*/
export default async function Uebersicht() { export default async function Uebersicht() {
// 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();
const jetzt = new Date();
const { letzterAbgleich, kennzahlen, mitAbweichung, weitereAbweichungen, baldAblaufend, letzteAktionen } =
await uebersicht(jetzt);
const verwalten = darf(benutzer.rolle, "hostsVerwalten");
return ( return (
<div className="flex flex-col gap-6 sm:gap-7"> <div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf <Seitenkopf
titel="Übersicht" titel="Übersicht"
badges={<Badge variante="messing">Prototyp · Beispieldaten</Badge>}
untertitel={ untertitel={
<> letzterAbgleich ? (
Letzter Abgleich {relativ(letzterAbgleich, BEISPIEL_JETZT)} ·{" "} <>
<time dateTime={letzterAbgleich} className="font-mono text-[13px]"> Letzter Abgleich {relativ(letzterAbgleich, jetzt)} ·{" "}
{datumZeit(letzterAbgleich)} <time dateTime={letzterAbgleich.toISOString()} className="font-mono text-[13px]">
</time> {datumZeit(letzterAbgleich)}
</> </time>
</>
) : (
"Noch kein Abgleich gelaufen."
)
} }
aktionen={ aktionen={
<> <>
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button> {verwalten && <AlleAbgleichenKnopf />}
<Button href="/zuweisungen" icon={<Route className="size-4" />}> {darf(benutzer.rolle, "privateKeyAusrollen") && (
Host-zu-Host-Assistent <Button href="/assistent" icon={<Route className="size-4" />}>
</Button> Host-zu-Host-Assistent
</Button>
)}
<Button <Button
href="/schluessel" href="/schluessel/neu"
variante="primaer" variante="primaer"
icon={<Plus className="size-4" strokeWidth={2.4} />} icon={<Plus className="size-4" strokeWidth={2.4} />}
> >
@@ -97,46 +101,60 @@ export default async function Uebersicht() {
</div> </div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
<Karte titel="Hosts mit Abweichung" untertitel="Ergebnis des letzten Abgleichs"> <Karte
<Tabelle titel="Hosts mit Abweichung"
beschriftung="Hosts mit Abweichung" untertitel={
spalten={[ weitereAbweichungen
{ titel: "Host" }, ? `Ergebnis des letzten Abgleichs · ${weitereAbweichungen} weitere`
{ titel: "Benutzer" }, : "Ergebnis des letzten Abgleichs"
{ titel: "Befund" }, }
{ titel: "Aktion", rechts: true, versteckt: true }, >
]} {mitAbweichung.length === 0 ? (
> <p className="text-ok text-[14px]">● Alle Hosts synchron – keine offenen Abweichungen.</p>
{abweichungen.map((a) => ( ) : (
<TabellenZeile key={`${a.host}-${a.benutzer}`}> <Tabelle
<TabellenZelle mono className="whitespace-nowrap"> beschriftung="Hosts mit Abweichung"
{a.host} spalten={[
</TabellenZelle> { titel: "Host" },
<TabellenZelle mono>{a.benutzer}</TabellenZelle> { titel: "Benutzer" },
<TabellenZelle className="whitespace-nowrap"> { titel: "Befund" },
<Statuspunkt status={a.status} text={a.befund} /> { titel: "Aktion", rechts: true, versteckt: true },
</TabellenZelle> ]}
<TabellenZelle rechts> >
<Link {mitAbweichung.map((a) => (
href="/hosts" <TabellenZeile key={`${a.hostId}-${a.benutzer}-${a.status}`}>
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline" <TabellenZelle mono className="whitespace-nowrap">
> {a.host}
Prüfen<span className="sr-only"> – {a.host}</span> </TabellenZelle>
</Link> <TabellenZelle mono>{a.benutzer}</TabellenZelle>
</TabellenZelle> <TabellenZelle className="whitespace-nowrap">
</TabellenZeile> <Statuspunkt status={a.status} text={a.befund} />
))} </TabellenZelle>
</Tabelle> <TabellenZelle rechts>
<Link
href={`/hosts/${a.hostId}`}
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
>
Prüfen<span className="sr-only"> – {a.host}</span>
</Link>
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
)}
</Karte> </Karte>
<Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen"> <Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen">
{baldAblaufend.length === 0 && (
<p className="text-muted text-[14px]">Kein Schlüssel läuft in den nächsten 30 Tagen ab.</p>
)}
<ul className="flex flex-col"> <ul className="flex flex-col">
{baldAblaufend.map((s) => { {baldAblaufend.map((s) => {
const tage = tageBis(s.ablauf, BEISPIEL_JETZT); const tage = tageBis(s.ablauf, jetzt);
return ( return (
<li key={s.zweck} className="border-border border-b last:border-b-0"> <li key={s.id} className="border-border border-b last:border-b-0">
<Link <Link
href="/schluessel" href={`/schluessel/${s.id}`}
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">
@@ -145,8 +163,16 @@ export default async function Uebersicht() {
{s.person} · <span className="font-mono">{s.typ}</span> {s.person} · <span className="font-mono">{s.typ}</span>
</span> </span>
</span> </span>
<Badge variante="messing" className="shrink-0"> <Badge variante={tage < 0 ? "gefahr" : "messing"} className="shrink-0">
<time dateTime={s.ablauf}>{tage === 1 ? "morgen" : `in ${tage} Tagen`}</time> <time dateTime={s.ablauf.toISOString()}>
{tage < 0
? `abgelaufen${s.ausgerollt ? " · noch ausgerollt" : ""}`
: tage === 0
? "heute"
: tage === 1
? "morgen"
: `in ${tage} Tagen`}
</time>
</Badge> </Badge>
</Link> </Link>
</li> </li>
@@ -167,13 +193,17 @@ export default async function Uebersicht() {
</Link> </Link>
} }
> >
{letzteAktionen.length === 0 && <Leerzustand titel="Noch keine Aktionen" />}
<ol className="flex flex-col"> <ol className="flex flex-col">
{letzteAktionen.map((a) => ( {letzteAktionen.map((a) => (
<li <li
key={a.zeit} key={a.id}
className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline" className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline"
> >
<time dateTime={a.zeit} className="text-muted font-mono text-[12.5px] whitespace-nowrap"> <time
dateTime={a.zeit.toISOString()}
className="text-muted font-mono text-[12.5px] whitespace-nowrap"
>
{datum(a.zeit)} {uhrzeit(a.zeit)} {datum(a.zeit)} {uhrzeit(a.zeit)}
</time> </time>
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span> <span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>
+193 -9
View File
@@ -1,18 +1,202 @@
import { Download, ScrollText, Search } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; import { KettenPruefung } from "@/components/protokoll/KettenPruefung";
import { ProtokollListe } from "@/components/protokoll/ProtokollListe";
import { Button, buttonKlassen } 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 { AKTIONSGRUPPEN } from "@/lib/protokoll-aktionen";
import {
cursorAus,
ERGEBNIS_TEXT,
protokollFilterAktiv,
protokollFilterAus,
protokollFilterQuery,
} from "@/lib/protokoll-filter";
import { syslogZielText } from "@/lib/syslog";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { protokollAkteure, protokollSeite, protokollZaehlen } from "@/server/queries/protokoll";
import { ketteZwischengespeichert, syslogKonfig } from "@/server/services/protokoll";
export const metadata: Metadata = { title: "Audit-Log" }; export const metadata: Metadata = { title: "Audit-Log" };
export default async function Protokoll() { type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
/**
* Audit-Log (#027): jede Rolle darf lesen. Filter per GET, damit Links, „ältere laden“ und
* der CSV-Export denselben Ausschnitt meinen. Es gibt hier bewusst keinen Knopf, der einen
* Eintrag aendert oder loescht.
*/
export default async function Protokoll({ 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(); await sitzungErforderlich("lesen", "/protokoll");
const parameter = await searchParams;
const filter = protokollFilterAus(parameter);
const vor = cursorAus(parameter["vor"]);
const [seite, zaehler, akteure] = await Promise.all([
protokollSeite(filter, vor),
protokollZaehlen(filter),
protokollAkteure(),
]);
const gefiltert = protokollFilterAktiv(filter);
const query = protokollFilterQuery(filter);
const kette = ketteZwischengespeichert();
const syslog = syslogKonfig();
return ( return (
<Platzhalterseite <div className="flex min-w-0 flex-col gap-6 sm:gap-7">
titel="Audit-Log" <Seitenkopf
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen" titel="Audit-Log"
phase={5} untertitel="Lückenloses, verkettetes Protokoll aller Aktionen – nur lesbar, nicht änderbar"
issue="#027" aktionen={
/> <a href={`/api/protokoll.csv${query}`} download className={buttonKlassen("sekundaer")}>
<Download className="size-4" aria-hidden />
CSV exportieren
</a>
}
/>
<Karte innen="ohne" className="divide-border divide-y">
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 px-5 py-4 sm:px-6">
<p className="text-[14px]">
<span className="font-display text-text text-[22px] font-semibold tabular-nums">
{zaehler.gesamt.toLocaleString("de-DE")}
</span>{" "}
<span className="text-muted">Einträge insgesamt</span>
</p>
{gefiltert && (
<p className="text-muted text-[14px]">
<span className="text-text font-medium tabular-nums">
{zaehler.treffer.toLocaleString("de-DE")}
</span>{" "}
passen zum Filter
</p>
)}
<p className="text-muted text-[13px] sm:ml-auto">
Syslog:{" "}
{syslog ? (
<span className="text-text font-mono">{syslogZielText(syslog)}</span>
) : (
<span>nicht eingerichtet</span>
)}
</p>
</div>
<div className="px-5 py-4 sm:px-6">
<KettenPruefung
anfang={
kette
? {
fehlerhafteId: kette.fehlerhafteId,
eintraege: kette.eintraege,
geprueftAm: kette.geprueftAm.toISOString(),
}
: null
}
/>
</div>
</Karte>
<form method="get" role="search" className="grid grid-cols-2 items-end gap-3 lg:grid-cols-4">
<Feld
beschriftung="Suche"
name="q"
type="search"
defaultValue={filter.q}
placeholder="Fingerprint, Host oder Benutzer"
autoComplete="off"
spellCheck={false}
className="col-span-2"
/>
<Feld
beschriftung="Host"
name="host"
defaultValue={filter.host}
placeholder="z. B. srv-pve01"
autoComplete="off"
spellCheck={false}
mono
className="col-span-2 sm:col-span-1"
/>
<Auswahlliste
beschriftung="Akteur"
name="akteur"
defaultValue={filter.akteur}
className="col-span-2 sm:col-span-1"
>
<option value="">Alle</option>
{/* Ein gefilterter Name, der nicht mehr unter den haeufigsten ist, bleibt waehlbar. */}
{[...new Set([...akteure, ...(filter.akteur ? [filter.akteur] : [])])].map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Aktion" name="gruppe" defaultValue={filter.gruppe}>
<option value="">Alle Aktionen</option>
{Object.entries(AKTIONSGRUPPEN).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Ergebnis" name="ergebnis" defaultValue={filter.ergebnis}>
<option value="">Alle</option>
{Object.entries(ERGEBNIS_TEXT).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<Feld beschriftung="Von" name="von" type="date" defaultValue={filter.von} />
<Feld beschriftung="Bis" name="bis" type="date" defaultValue={filter.bis} />
<div className="col-span-2 flex gap-2.5 sm:justify-end lg:col-span-4">
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
Filtern
</Button>
{gefiltert && (
<Button href="/protokoll" className="flex-1 sm:flex-none">
Zurücksetzen
</Button>
)}
</div>
</form>
{vor !== undefined && (
<p className="text-muted text-[14px]">
Einträge älter als #{String(vor)}.{" "}
<a href={`/protokoll${query}`} className="text-accent underline-offset-2 hover:underline">
Zu den neuesten
</a>
</p>
)}
{seite.zeilen.length === 0 ? (
gefiltert ? (
<Leerzustand
icon={<Search className="size-5" />}
titel="Keine Treffer"
text="Kein Eintrag passt zu Suche und Filter."
aktion={<Button href="/protokoll">Filter zurücksetzen</Button>}
/>
) : (
<Leerzustand
icon={<ScrollText className="size-5" />}
titel="Noch keine Einträge"
text="Sobald jemand sich anmeldet oder etwas ändert, steht es hier."
/>
)
) : (
<Karte>
{/* key: neue Filter oder ein neuer Cursor beginnen eine frische Liste. */}
<ProtokollListe
key={`${query}|${String(vor ?? "")}`}
seite={seite}
query={query.replace(/^\?/, "")}
/>
</Karte>
)}
</div>
); );
} }
+233 -9
View File
@@ -1,4 +1,4 @@
import { Download, Lock, LockOpen, Pencil } from "lucide-react"; import { Download, Lock, LockOpen, Pencil, RefreshCw } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
@@ -15,10 +15,16 @@ import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
import { datum, datumZeit } from "@/lib/format"; import { datum, datumZeit } from "@/lib/format";
import { darf } from "@/server/auth/rechte"; import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel"; import { rotationsKette, schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen"; import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
import { zielAuswahl } from "@/server/queries/ziele"; import { zielAuswahl } from "@/server/queries/ziele";
import { widerrufUebersicht } from "@/server/queries/widerruf";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
import { ablaufZustand } from "@/lib/schluessel";
import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen";
import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation";
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion"; import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen"; import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
import { PublicAusrollenAktion } from "./PublicAusrollenAktion"; import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
@@ -187,18 +193,78 @@ function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNo
} }
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */ /** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) { export default async function SchluesselSeite({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ zielkonten?: string | string[] }>;
}) {
const { id } = await params; const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`); const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
const s = await schluesselDetail(id); const s = await schluesselDetail(id);
if (!s) notFound(); if (!s) notFound();
const zielkontenParameter = (await searchParams).zielkonten;
const aktiv = s.status === "AKTIV"; // Im Widerruf (#024) bleibt der Status AKTIV, bis alles entfernt ist – zuweisen geht aber nicht mehr.
const imWiderruf = Boolean(s.widerrufEingeleitetAm) && (s.status === "AKTIV" || s.status === "ABGELAUFEN");
const aktiv = s.status === "AKTIV" && !imWiderruf;
const rechte: Rechte = { const rechte: Rechte = {
public: darf(benutzer.rolle, "publicKeyAusrollen"), public: darf(benutzer.rolle, "publicKeyAusrollen"),
privat: darf(benutzer.rolle, "privateKeyAusrollen"), privat: darf(benutzer.rolle, "privateKeyAusrollen"),
testen: darf(benutzer.rolle, "hostsVerwalten"), testen: darf(benutzer.rolle, "hostsVerwalten"),
}; };
const jetzt = new Date();
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
const rotationDesVorgaengers =
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
? s.vorgaenger
: null;
const widerrufbar =
!imWiderruf &&
!(s.nachfolger?.status === "AKTIV") &&
// Ein Nachfolger mitten in der Rotation wird ueber „Rotation abbrechen“ widerrufen (#032) –
// so steht der Abbruch im Protokoll und die Rotationsseite kennt den Stand.
!rotationDesVorgaengers &&
s.kategorie !== "DEPLOYMENT" &&
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
rechte.public &&
(s.privateVerwendungen.length === 0 || rechte.privat);
const widerruf = widerrufbar || imWiderruf ? await widerrufUebersicht([s.id]) : undefined;
// Rotation (#023): Kette, Knopf bzw. Hinweis fuer Schluessel ohne Private Key in sshkv.
const kette = await rotationsKette(s.id);
const aktivePrivat = s.privateVerwendungen.filter((v) => v.zustand !== "ENTFERNEN").length;
const rotationsSperre = rotationGesperrt({
kategorie: s.kategorie,
status: s.status,
masterKeyVersion: s.privatGespeichert ? 1 : null,
widerrufEingeleitetAm: s.widerrufEingeleitetAm,
privatZuweisungen: aktivePrivat,
});
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
// Nach einem abgeschlossenen Abbruch (#032) darf erneut rotiert werden (#033); solange der
// abgebrochene Nachfolger noch entfernt wird, nicht.
const rotierbar =
darfRotieren &&
(rotationLaeuft ||
(!rotationsSperre && !imWiderruf && (!s.nachfolger || s.nachfolger.status === "WIDERRUFEN")));
// Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
const darfAbbrechen =
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
!imWiderruf &&
s.status === "AKTIV" &&
verwaltenRecht &&
rechte.public &&
(s.privateVerwendungen.length === 0 || rechte.privat);
const vorauswahl = (
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
)
.split(",")
.filter(Boolean);
const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined; const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined;
const personenKey = s.kategorie === "PERSON"; const personenKey = s.kategorie === "PERSON";
const verwalten = darf(benutzer.rolle, "schluesselVerwalten"); const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
@@ -206,7 +272,6 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT"; const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen"); const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen"); const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
const jetzt = new Date();
return ( return (
<div className="flex flex-col gap-6 sm:gap-7"> <div className="flex flex-col gap-6 sm:gap-7">
@@ -228,6 +293,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
<TypBadge typ={s.typ} publicKey={s.publicKey} /> <TypBadge typ={s.typ} publicKey={s.publicKey} />
<KategorieBadge kategorie={s.kategorie} /> <KategorieBadge kategorie={s.kategorie} />
<StatusBadge status={s.status} /> <StatusBadge status={s.status} />
{imWiderruf && <Badge variante="gefahr">Widerruf unvollständig</Badge>}
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>} {s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>} {s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>} {s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
@@ -240,14 +306,121 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
</> </>
} }
aktionen={ aktionen={
verwalten && ( (verwalten || rotierbar || (widerrufbar && widerruf)) && (
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}> <>
Bearbeiten {verwalten && (
</Button> <Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
Bearbeiten
</Button>
)}
{rotierbar && (
<Button href={`/schluessel/${s.id}/rotieren`} icon={<RefreshCw className="size-4" />}>
{rotationLaeuft ? "Rotation fortsetzen" : "Rotieren"}
</Button>
)}
{widerrufbar && widerruf && (
<WiderrufKnopf uebersicht={widerruf} ruecksprung={`/schluessel/${s.id}`} />
)}
</>
) )
} }
/> />
{rotationDesVorgaengers &&
(imWiderruf || s.status === "WIDERRUFEN" ? (
<Karte titel="Rotation abgebrochen">
<p className="text-muted text-[14px]">
Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen.
{imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "}
<Link href={`/schluessel/${rotationDesVorgaengers.id}`} className="text-accent hover:underline">
{rotationDesVorgaengers.name}
</Link>{" "}
bleibt aktiv.
</p>
</Karte>
) : (
<Karte titel="Rotation läuft" className="border-accent">
<p className="text-muted text-[14px]">
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "}
<Link
href={`/schluessel/${rotationDesVorgaengers.id}/rotieren`}
className="text-accent hover:underline"
>
Rotation fortsetzen
</Link>
</p>
{darfAbbrechen && (
<div className="mt-4">
<RotationAbbrechen
altId={rotationDesVorgaengers.id}
altName={rotationDesVorgaengers.name}
neuName={s.name}
/>
</div>
)}
</Karte>
))}
{imWiderruf && widerruf && (
<Karte
titel="Widerruf läuft"
untertitel="Der Schlüssel gilt erst als widerrufen, wenn er von allen Hosts entfernt ist."
className="border-danger-border"
>
<WiderrufAblauf
schluesselIds={[s.id]}
offen={widerruf.offen}
zielkonten={widerruf.zielkonten}
vorauswahl={vorauswahl}
ruecksprung={`/schluessel/${s.id}`}
darfPrivat={rechte.privat}
ohneZielkonten={rotationLaeuft || Boolean(rotationDesVorgaengers)}
/>
</Karte>
)}
{abgelaufen && widerrufbar && widerruf && (
<Karte titel="Abgelaufen" className="border-danger-border">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted max-w-xl text-[14px]">
{s.ablaufAm ? (
<>
Das Ablaufdatum (<time dateTime={s.ablaufAm.toISOString()}>{datum(s.ablaufAm)}</time>) ist
überschritten.{" "}
</>
) : null}
Nach Projektende bzw. Ablaufdatum: Widerruf mit einem Klick. sshkv entfernt abgelaufene
Schlüssel nie von selbst – erst nach Ihrer Bestätigung und dem Dry-Run.
</p>
<WiderrufKnopf
uebersicht={widerruf}
ruecksprung={`/schluessel/${s.id}`}
beschriftung="Jetzt widerrufen"
/>
</div>
</Karte>
)}
{(s.status === "WIDERRUFEN" || s.status === "ROTIERT") && (
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
<p className="text-muted text-[14px]">
Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen.
{/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */}
{s.nachfolger && s.status === "ROTIERT" && (
<>
{" "}
Ersetzt durch{" "}
<Link href={`/schluessel/${s.nachfolger.id}`} className="text-accent hover:underline">
{s.nachfolger.name}
</Link>
.
</>
)}
</p>
</Karte>
)}
<Karte as="div" innen="ohne"> <Karte as="div" innen="ohne">
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0"> <dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
<InfoFeld titel="Fingerprint"> <InfoFeld titel="Fingerprint">
@@ -321,8 +494,13 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
</div> </div>
{rechte.public && {rechte.public &&
!imWiderruf &&
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && ( (s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
<Karte <Karte
// Sprungziel fuer „Zum Dry-Run“ aus der Zuweisungsmatrix; scroll-mt haelt die Karte
// unter der festen Kopfleiste der schmalen Ansicht (#032).
id="dry-run"
className="scroll-mt-20"
titel="Dry-Run-Vorschau" titel="Dry-Run-Vorschau"
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts" untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
> >
@@ -336,6 +514,52 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
</Karte> </Karte>
)} )}
{(kette.length > 1 || (rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf)) && (
<Karte
titel="Rotation"
untertitel={kette.length > 1 ? "Vorgänger und Nachfolger dieses Schlüssels" : undefined}
>
{kette.length > 1 && (
<ol className="flex flex-wrap items-center gap-2 text-[14px]" aria-label="Rotationskette">
{kette.map((glied, index) => (
<li key={glied.id} className="flex items-center gap-2">
{index > 0 && (
<span className="text-muted" aria-hidden>
→
</span>
)}
{glied.id === s.id ? (
<span className="font-medium" aria-current="true">
{glied.name}
</span>
) : (
<Link href={`/schluessel/${glied.id}`} className="text-accent hover:underline">
{glied.name}
</Link>
)}
<StatusBadge status={glied.status} />
</li>
))}
</ol>
)}
{abgebrocheneRotation && s.nachfolger && (
<p className="text-muted mt-3 text-[14px]">
Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen
{s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"};
dieser Schlüssel bleibt aktiv
{s.nachfolger.status === "WIDERRUFEN"
? " und lässt sich erneut rotieren."
: ". Danach lässt er sich erneut rotieren."}
</p>
)}
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
{rotationsSperre.grund}
</p>
)}
</Karte>
)}
<Karte <Karte
titel="Public Key" titel="Public Key"
untertitel={ untertitel={
@@ -0,0 +1,92 @@
import { KeyRound } from "lucide-react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Rotation } from "@/components/rotation/Rotation";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Button } from "@/components/ui/Button";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { RotationsFehler, rotationsStand } from "@/server/services/rotation";
import { offeneEntfernungen } from "@/server/services/widerruf";
export const metadata: Metadata = { title: "Schlüssel rotieren" };
/** Gefuehrte Rotation (#023): Schrittfolge, wiederaufnehmbar. */
export default async function RotierenSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/rotieren`);
if (!darf(benutzer.rolle, "publicKeyAusrollen")) notFound();
let stand;
try {
stand = await rotationsStand(id);
} catch (fehler) {
if (fehler instanceof RotationsFehler) notFound();
throw fehler;
}
const { alt } = stand;
const kopf = (
<Seitenkopf
oben={
<Breadcrumb
pfad={[
{ titel: "Schlüssel", href: "/schluessel" },
{ titel: alt.name, href: `/schluessel/${alt.id}` },
{ titel: "Rotieren" },
]}
/>
}
titel={`„${alt.name}“ rotieren`}
untertitel="Neuer Schlüssel an allen Stellen, testen, erst dann den alten entfernen."
/>
);
// Ohne Nachfolger und nicht rotierbar: nur der Hinweis, keine halbe Rotation.
if (!stand.neu && stand.gesperrt) {
return (
<div className="flex flex-col gap-6 sm:gap-7">
{kopf}
<Leerzustand
icon={<KeyRound className="size-5" />}
titel={stand.gesperrt.neuerPublicKeyNoetig ? "Neuer Public Key nötig" : "Nicht rotierbar"}
text={stand.gesperrt.grund}
aktion={<Button href={`/schluessel/${alt.id}`}>Zurück zum Schlüssel</Button>}
/>
</div>
);
}
const widerruf =
stand.neu && !stand.abgebrochen && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
? await offeneEntfernungen([alt.id])
: null;
// Abbruch (#032): solange der neue Schluessel nicht widerrufen ist, laeuft sein Widerruf hier weiter.
const abbruch =
stand.neu && stand.abgebrochen
? { offen: stand.neu.status === "WIDERRUFEN" ? null : await offeneEntfernungen([stand.neu.id]) }
: null;
return (
<div className="flex flex-col gap-6 sm:gap-7">
{kopf}
<Rotation
daten={{
alt: { id: alt.id, name: alt.name },
neu: stand.neu ? { id: stand.neu.id, name: stand.neu.name } : null,
schritt: stand.schritt,
neuOffen: stand.neu ? stand.neuOffen : 0,
tests: stand.neu ? stand.tests : [],
widerruf,
abbruch,
letzterAbbruch:
!stand.neu && stand.alt.nachfolger?.status === "WIDERRUFEN"
? { id: stand.alt.nachfolger.id, name: stand.alt.nachfolger.name }
: null,
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
}}
/>
</div>
);
}
+54 -3
View File
@@ -1,8 +1,10 @@
import { KeyRound, Lock, Plus, Search } from "lucide-react"; import { Ban, KeyRound, Lock, Plus, Search } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige"; import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { AlleAuswaehlen } from "@/components/widerruf/AlleAuswaehlen";
import { Auswahlliste, Feld } from "@/components/ui/Feld"; import { Auswahlliste, Feld } from "@/components/ui/Feld";
import { Karte } from "@/components/ui/Karte"; import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand"; import { Leerzustand } from "@/components/ui/Leerzustand";
@@ -50,6 +52,31 @@ function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) {
); );
} }
const MASSEN_FORMULAR = "massenwiderruf";
/** Nur Schluessel, die sich widerrufen lassen, sind waehlbar (#024). */
function Auswahl({ s }: { s: SchluesselListenEintrag }) {
const gesperrt = s.kategorie === "DEPLOYMENT" || s.status === "WIDERRUFEN" || s.status === "ROTIERT";
return (
<label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
<input
type="checkbox"
name="id"
value={s.id}
form={MASSEN_FORMULAR}
disabled={gesperrt}
aria-label={`${s.name} für den Massenwiderruf auswählen`}
className="accent-accent size-[18px] cursor-pointer disabled:cursor-not-allowed disabled:opacity-30"
/>
</label>
);
}
function WiderrufBadge({ s }: { s: SchluesselListenEintrag }) {
if (!s.widerrufEingeleitetAm || (s.status !== "AKTIV" && s.status !== "ABGELAUFEN")) return null;
return <Badge variante="gefahr">Widerruf unvollständig</Badge>;
}
/** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */ /** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */
export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) { export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
@@ -60,6 +87,8 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
const status = einzeln(parameter["status"]); const status = einzeln(parameter["status"]);
const liste = await schluesselListe({ q, kategorie, status }); const liste = await schluesselListe({ q, kategorie, status });
const verwalten = darf(benutzer.rolle, "schluesselVerwalten"); const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
// Massenwiderruf (#024): Auswahl nur fuer Admins; die frische Bestaetigung prueft die Aktion.
const massen = darf(benutzer.rolle, "massenwiderruf");
const gefiltert = q !== "" || kategorie !== "" || status !== ""; const gefiltert = q !== "" || kategorie !== "" || status !== "";
const jetzt = new Date(); const jetzt = new Date();
@@ -142,13 +171,27 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
) )
) : ( ) : (
<Karte> <Karte>
{massen && (
<form
id={MASSEN_FORMULAR}
method="get"
action="/schluessel/widerruf"
className="border-border mb-3 flex flex-wrap items-center justify-between gap-2 border-b pb-3"
>
<AlleAuswaehlen formular={MASSEN_FORMULAR} />
<Button type="submit" variante="gefahr" klein icon={<Ban className="size-4" />}>
Ausgewählte widerrufen
</Button>
</form>
)}
{/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */} {/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */}
<ul className="flex flex-col md:hidden"> <ul className="flex flex-col md:hidden">
{liste.map((s) => ( {liste.map((s) => (
<li key={s.id} className="border-border border-b last:border-b-0"> <li key={s.id} className="border-border flex items-start gap-1 border-b last:border-b-0">
{massen && <Auswahl s={s} />}
<Link <Link
href={`/schluessel/${s.id}`} href={`/schluessel/${s.id}`}
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-2 px-2 py-3 transition-colors" className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 min-w-0 flex-1 flex-col gap-2 px-2 py-3 transition-colors"
> >
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium"> <span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
<span className="truncate">{s.name}</span> <span className="truncate">{s.name}</span>
@@ -161,6 +204,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
<TypBadge typ={s.typ} publicKey={s.publicKey} /> <TypBadge typ={s.typ} publicKey={s.publicKey} />
<KategorieBadge kategorie={s.kategorie} /> <KategorieBadge kategorie={s.kategorie} />
<StatusBadge status={s.status} /> <StatusBadge status={s.status} />
<WiderrufBadge s={s} />
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} /> <AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
</span> </span>
<span className="text-muted text-[13px]"> <span className="text-muted text-[13px]">
@@ -175,6 +219,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
className="hidden md:block" className="hidden md:block"
beschriftung="Schlüssel" beschriftung="Schlüssel"
spalten={[ spalten={[
...(massen ? [{ titel: "Auswahl", versteckt: true, className: "w-10" }] : []),
{ titel: "Name" }, { titel: "Name" },
{ titel: "Typ" }, { titel: "Typ" },
{ titel: "Kategorie" }, { titel: "Kategorie" },
@@ -185,6 +230,11 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
> >
{liste.map((s) => ( {liste.map((s) => (
<TabellenZeile key={s.id}> <TabellenZeile key={s.id}>
{massen && (
<TabellenZelle className="w-10">
<Auswahl s={s} />
</TabellenZelle>
)}
<TabellenZelle> <TabellenZelle>
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<Link <Link
@@ -195,6 +245,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
</Link> </Link>
<PrivatHinweis s={s} /> <PrivatHinweis s={s} />
<StatusBadge status={s.status} /> <StatusBadge status={s.status} />
<WiderrufBadge s={s} />
</span> </span>
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>} {s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
</TabellenZelle> </TabellenZelle>
+111
View File
@@ -0,0 +1,111 @@
import { Ban } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { StatusBadge } from "@/components/schluessel/Anzeige";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Button } from "@/components/ui/Button";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular";
import { idsAusSuchparameter } from "@/lib/schluessel";
import { darf } from "@/server/auth/rechte";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { widerrufUebersicht } from "@/server/queries/widerruf";
export const metadata: Metadata = { title: "Massenwiderruf" };
type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>;
/**
* Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem
* Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische
* Bestaetigung (Recht `massenwiderruf`). Protokolliert wird je Schluessel.
*/
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
const parameter = await searchParams;
const ids = idsAusSuchparameter(parameter.id);
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
const kopf = (
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: "Massenwiderruf" }]} />}
titel="Massenwiderruf"
untertitel="Mehrere Schlüssel gemeinsam von allen Hosts entfernen – ein Dry-Run für alle."
/>
);
if (ids.length === 0) {
return (
<div className="flex flex-col gap-6 sm:gap-7">
{kopf}
<Leerzustand
icon={<Ban className="size-5" />}
titel="Keine Schlüssel gewählt"
text="Wählen Sie in der Schlüsselliste die Schlüssel aus, die widerrufen werden sollen."
aktion={<Button href="/schluessel">Zur Schlüsselliste</Button>}
/>
</div>
);
}
const uebersicht = await widerrufUebersicht(ids);
const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN";
const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet);
const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet);
const vorauswahl = idsAusSuchparameter(parameter.zielkonten);
return (
<div className="flex flex-col gap-6 sm:gap-7">
{kopf}
{nochNichtEingeleitet.length > 0 || uebersicht.schluessel.some((s) => s.gesperrt && !s.eingeleitet) ? (
<Karte
titel={`${uebersicht.schluessel.length} Schlüssel widerrufen?`}
untertitel="Genau das passiert – erst nach Dry-Run und „Anwenden“ auf den Hosts."
>
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
<p className="text-accent mb-4 text-[13.5px]">
Der Massenwiderruf verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
Vormerken danach.
</p>
)}
<WiderrufFormular uebersicht={uebersicht} ruecksprung={selbst} />
</Karte>
) : laufend.length > 0 ? (
<Karte
titel="Widerruf läuft"
untertitel="Ein gemeinsamer Dry-Run für alle Schlüssel. Widerrufen ist ein Schlüssel erst, wenn er überall entfernt ist."
className="border-danger-border"
>
<WiderrufAblauf
schluesselIds={laufend.map((s) => s.id)}
offen={uebersicht.offen}
zielkonten={uebersicht.zielkonten}
vorauswahl={vorauswahl}
ruecksprung={selbst}
darfPrivat={darf(benutzer.rolle, "privateKeyAusrollen")}
/>
</Karte>
) : null}
<Karte titel="Gewählte Schlüssel">
<ul className="flex flex-col gap-2">
{uebersicht.schluessel.map((s) => (
<li key={s.id} className="flex flex-wrap items-center gap-2 text-[14px]">
<Link href={`/schluessel/${s.id}`} className="text-accent hover:underline">
{s.name}
</Link>
{s.person && <span className="text-muted">· {s.person}</span>}
<StatusBadge status={s.status} />
{s.eingeleitet && offeneStati(s) && (
<span className="text-danger text-[13px]">Widerruf unvollständig</span>
)}
</li>
))}
</ul>
</Karte>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { VorlageArchivierenDialog } from "@/components/vorlagen/VorlageArchivierenDialog";
import { VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { vorlageDetail } from "@/server/queries/vorlagen";
export const metadata: Metadata = { title: "Vorlage bearbeiten" };
export default async function VorlageBearbeiten({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await sitzungErforderlich("einstellungenVerwalten", `/vorlagen/${id}`);
const v = await vorlageDetail(id);
// Archivierte Vorlagen sind fuer die Oberflaeche weg; das Protokoll kennt sie noch (#019).
if (!v || v.archiviertAm) notFound();
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: v.name }]} />}
titel={`${v.name} bearbeiten`}
badges={v.eingebaut ? <Badge>eingebaut</Badge> : undefined}
untertitel={
v.eingebaut
? "Eingebaute Vorlagen lassen sich anpassen, aber nicht archivieren. Ein späterer Seed überschreibt Ihre Änderungen nicht."
: undefined
}
aktionen={!v.eingebaut && <VorlageArchivierenDialog vorlageId={v.id} name={v.name} />}
/>
<VorlagenFormular
vorgabe={{
id: v.id,
name: v.name,
beschreibung: v.beschreibung ?? "",
// Das Formular kennt nur, was Vorlagen erzeugen; ANDERER/DEPLOYMENT kommen hier nicht vor.
schluesselTyp: v.schluesselTyp === "RSA4096" ? "RSA4096" : "ED25519",
kategorie: v.kategorie === "PERSON" ? "PERSON" : "DIENST",
standardLaufzeitTage: v.standardLaufzeitTage,
zielbenutzer: v.zielbenutzer,
zielbenutzerAnlegen: v.zielbenutzerAnlegen,
sudoModus: v.sudoModus,
sudoBefehle: v.sudoBefehle,
authorizedKeysOptionen: v.authorizedKeysOptionen ?? "",
sshConfigAlias: v.sshConfigAlias ?? "",
knownHostsEintragen: v.knownHostsEintragen,
privatNachAusrollenLoeschen: v.privatNachAusrollenLoeschen,
}}
/>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { LEERE_VORLAGE, VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Vorlage anlegen" };
export default async function VorlageNeu() {
// Vorlagen pflegen nur Admins (#019) – wie die uebrigen Einstellungen.
await sitzungErforderlich("einstellungenVerwalten", "/vorlagen/neu");
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: "Neue Vorlage" }]} />}
titel="Vorlage anlegen"
untertitel="Eine Vorlage füllt den Assistenten vor; im Einzelfall lässt sich alles noch ändern."
/>
<VorlagenFormular vorgabe={LEERE_VORLAGE} />
</div>
);
}
+129 -10
View File
@@ -1,18 +1,137 @@
import { FileStack, Pencil, Plus } from "lucide-react";
import type { Metadata } from "next"; import 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>
); );
} }
+155 -9
View File
@@ -1,18 +1,164 @@
import { Download, Network, Search } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; import { Zuweisungsmatrix } from "@/components/zuweisungen/Zuweisungsmatrix";
import { Button, buttonKlassen } from "@/components/ui/Button";
import { Ankreuzfeld, Auswahlliste, Feld } from "@/components/ui/Feld";
import { Kennzahl } from "@/components/ui/Kennzahl";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { KATEGORIE_TEXT } from "@/lib/schluessel";
import { matrixFilterAus, matrixFilterQuery } from "@/lib/zuweisungsmatrix";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { zielAuswahl } from "@/server/queries/ziele";
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
export const metadata: Metadata = { title: "Zuweisungen" }; export const metadata: Metadata = { title: "Zuweisungen" };
export default async function Zuweisungen() { type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
/**
* Zuweisungsmatrix Schluessel × Host (#025). Filter per GET, damit Links und der CSV-Export
* denselben Ausschnitt zeigen. Die Matrix plant nur – ausgerollt wird ueber den Dry-Run.
*/
export default async function Zuweisungen({ searchParams }: { searchParams: Suchparameter }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich(); const { benutzer } = await sitzungErforderlich("lesen", "/zuweisungen");
const filter = matrixFilterAus(await searchParams);
const darfZuweisen = darf(benutzer.rolle, "publicKeyAusrollen");
const [{ matrix, gruppen, personen }, auswahl] = await Promise.all([
zuweisungsMatrix(filter),
darfZuweisen ? zielAuswahl() : Promise.resolve(undefined),
]);
const gefiltert = Boolean(
filter.gruppeId || filter.kategorie || filter.personId || filter.q || filter.nurBelegt,
);
const { zaehler } = matrix;
const leer = matrix.zeilen.length === 0 || matrix.spalten.length === 0;
return ( return (
<Platzhalterseite <div className="flex min-w-0 flex-col gap-6 sm:gap-7">
titel="Zuweisungen" <Seitenkopf
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt" titel="Zuweisungen"
phase={2} untertitel="Welcher Schlüssel auf welchem Host verwaltet ist"
issue="#016" aktionen={
/> <a
href={`/api/zuweisungen.csv${matrixFilterQuery(filter)}`}
download
className={buttonKlassen("sekundaer")}
>
<Download className="size-4" aria-hidden />
CSV exportieren
</a>
}
/>
<div className="grid grid-cols-2 gap-3 sm:gap-5 xl:grid-cols-4">
<Kennzahl titel="Schlüssel" wert={zaehler.schluessel} hinweis="aktiv, in der Ansicht" />
<Kennzahl titel="Hosts" wert={zaehler.hosts} hinweis="nicht archiviert" />
<Kennzahl titel="Zuweisungen" wert={zaehler.zuweisungen} hinweis="Gruppen je Host gezählt" />
<Kennzahl
titel="Nicht synchron"
wert={zaehler.nichtSynchron}
variante={zaehler.nichtSynchron > 0 ? "messing" : "neutral"}
hinweis="geplant, Drift, entfernen, Fehler"
/>
</div>
<form
method="get"
role="search"
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_10rem_11rem]"
>
<Feld
beschriftung="Suche"
name="q"
type="search"
defaultValue={filter.q}
placeholder="Schlüssel- oder Hostname"
autoComplete="off"
spellCheck={false}
className="sm:col-span-2 lg:col-span-1"
/>
<Auswahlliste beschriftung="Hostgruppe" name="gruppe" defaultValue={filter.gruppeId}>
<option value="">Alle Hosts</option>
{gruppen.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={filter.kategorie}>
<option value="">Alle</option>
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Person" name="person" defaultValue={filter.personId}>
<option value="">Alle Personen</option>
{personen.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Auswahlliste>
<div className="flex flex-col gap-3 sm:col-span-2 sm:flex-row sm:items-center sm:justify-between lg:col-span-4">
<Ankreuzfeld
name="belegt"
value="1"
defaultChecked={filter.nurBelegt}
beschriftung="Nur Zeilen und Spalten mit Zuweisungen"
className="-mx-2"
/>
<div className="flex gap-2.5">
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
Filtern
</Button>
{gefiltert && (
<Button href="/zuweisungen" className="flex-1 sm:flex-none">
Zurücksetzen
</Button>
)}
</div>
</div>
</form>
{leer ? (
gefiltert ? (
<Leerzustand
icon={<Search className="size-5" />}
titel="Keine Treffer"
text="Kein Schlüssel und kein Host passen zu Suche und Filter."
aktion={<Button href="/zuweisungen">Filter zurücksetzen</Button>}
/>
) : (
<Leerzustand
icon={<Network className="size-5" />}
titel="Noch nichts zuzuordnen"
text="Die Matrix braucht mindestens einen aktiven Schlüssel und einen Host."
/>
)
) : (
<section className="flex min-w-0 flex-col gap-3" aria-label="Matrix">
<p className="text-muted text-[13px]">
<span className="text-text font-medium">P</span> Public Key direkt ·{" "}
<span className="text-text font-medium">P·G</span> über Hostgruppe ·{" "}
<span className="text-text font-medium">K</span> Private Key · <span className="text-ok">●</span>{" "}
synchron · <span className="text-accent">○</span> geplant oder wird entfernt ·{" "}
<span className="text-danger">●</span> Drift oder Fehler
</p>
<Zuweisungsmatrix
zeilen={matrix.zeilen}
spalten={matrix.spalten}
zellen={Object.fromEntries(matrix.zellen)}
darfZuweisen={darfZuweisen}
darfPrivatEntfernen={darf(benutzer.rolle, "privateKeyAusrollen")}
{...(auswahl ? { auswahl } : {})}
/>
</section>
)}
</div>
); );
} }
+8 -4
View File
@@ -1,8 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen"; import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
import { NotfallFormular } from "@/components/anmeldung/NotfallFormular"; import { KontoFormular } from "@/components/anmeldung/KontoFormular";
import { buttonKlassen, Formularfehler } from "@/components/ui"; import { buttonKlassen, Formularfehler } from "@/components/ui";
import { lokaleKontenAktiv } from "@/lib/lokale-konten";
import { sicheresZiel } from "@/server/auth/anmeldung"; import { sicheresZiel } from "@/server/auth/anmeldung";
import { oidcAktiv, oidcName } from "@/server/auth/oidc"; import { oidcAktiv, oidcName } from "@/server/auth/oidc";
import { angemeldet } from "@/server/auth/sitzungspflicht"; import { angemeldet } from "@/server/auth/sitzungspflicht";
@@ -33,6 +34,7 @@ export default async function AnmeldenSeite({
if (await angemeldet()) redirect(ziel); if (await angemeldet()) redirect(ziel);
const oidc = oidcAktiv(); const oidc = oidcAktiv();
const lokal = lokaleKontenAktiv();
const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined; const fehler = parameter.fehler ? (FEHLER[parameter.fehler] ?? FEHLER["allgemein"]) : undefined;
return ( return (
@@ -53,15 +55,17 @@ export default async function AnmeldenSeite({
</a> </a>
) : ( ) : (
<p className="text-muted text-[14px]"> <p className="text-muted text-[14px]">
Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv. {lokal
? "Kein Identitätsanbieter eingerichtet – Anmeldung mit lokalem Konto."
: "Kein Identitätsanbieter eingerichtet – nur der Notfallzugang ist aktiv."}
</p> </p>
)} )}
<details className="group border-border rounded-box border" open={!oidc}> <details className="group border-border rounded-box border" open={!oidc}>
<summary className="text-muted hover:text-text flex min-h-11 cursor-pointer items-center px-4 text-[14px] font-medium"> <summary className="text-muted hover:text-text flex min-h-11 cursor-pointer items-center px-4 text-[14px] font-medium">
Notfallzugang {lokal ? "Lokales Konto oder Notfallzugang" : "Notfallzugang"}
</summary> </summary>
<div className="border-border border-t p-4"> <div className="border-border border-t p-4">
<NotfallFormular ziel={ziel} /> <KontoFormular ziel={ziel} lokal={lokal} />
</div> </div>
</details> </details>
</div> </div>
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
import { PasswortSetzenFormular } from "@/components/anmeldung/PasswortSetzenFormular";
import { KONTO_PASSWORT_MINDESTLAENGE } from "@/lib/lokale-konten";
import { passwortSetzenLesen } from "@/server/auth/totp-einrichtung";
export const metadata: Metadata = { title: "Eigenes Passwort setzen" };
/**
* Erste Anmeldung eines lokalen Kontos (#035): das Einmal-Passwort vom Admin wird durch ein
* eigenes ersetzt. Nur mit dem Cookie erreichbar, das ein richtiges Einmal-Passwort ausstellt.
*/
export default async function PasswortSetzenSeite() {
if (!(await passwortSetzenLesen())) redirect("/anmelden");
return (
<AnmeldeRahmen
titel="Eigenes Passwort setzen"
text={`Das Einmal-Passwort gilt nur für diese erste Anmeldung. Bitte ein eigenes mit mindestens ${KONTO_PASSWORT_MINDESTLAENGE} Zeichen wählen – danach wird der zweite Faktor eingerichtet.`}
>
<PasswortSetzenFormular />
</AnmeldeRahmen>
);
}
+13 -3
View File
@@ -4,20 +4,30 @@ import QRCode from "qrcode";
import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen"; import { AnmeldeRahmen } from "@/components/anmeldung/AnmeldeRahmen";
import { TotpFormular } from "@/components/anmeldung/TotpFormular"; import { TotpFormular } from "@/components/anmeldung/TotpFormular";
import { MonoKopieren } from "@/components/ui"; import { MonoKopieren } from "@/components/ui";
import { prisma } from "@/lib/db";
import { totpAdresse } from "@/lib/totp"; import { totpAdresse } from "@/lib/totp";
import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung"; import { totpEinrichtungLesen } from "@/server/auth/totp-einrichtung";
export const metadata: Metadata = { title: "TOTP einrichten" }; export const metadata: Metadata = { title: "TOTP einrichten" };
/** /**
* Erste Anmeldung des Notfall-Admins: TOTP ist Pflicht (Freigabe Frage 5). Die Seite ist * Erste Anmeldung eines Passwort-Kontos (Notfall-Admin, lokale Konten #035): TOTP ist Pflicht
* nur mit dem Einrichtungs-Cookie erreichbar, das ein richtiges Passwort ausgestellt hat. * (Freigabe Frage 5). Die Seite ist nur mit dem Einrichtungs-Cookie erreichbar, das ein
* richtiges Passwort ausgestellt hat.
*/ */
export default async function TotpSeite() { export default async function TotpSeite() {
const einrichtung = await totpEinrichtungLesen(); const einrichtung = await totpEinrichtungLesen();
if (!einrichtung) redirect("/anmelden"); if (!einrichtung) redirect("/anmelden");
const adresse = totpAdresse(einrichtung.geheimnis, "Notfall-Admin"); const konto = await prisma.benutzer.findUnique({
where: { id: einrichtung.benutzerId },
select: { anmeldename: true, istNotfall: true },
});
// In der Authenticator-App soll stehen, welches Konto das ist.
const adresse = totpAdresse(
einrichtung.geheimnis,
konto?.istNotfall ? "Notfall-Admin" : (konto?.anmeldename ?? "Konto"),
);
// Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser. // Als SVG im Server gerendert: kein Bild-Endpunkt, keine Bibliothek im Browser.
const svg = await QRCode.toString(adresse, { const svg = await QRCode.toString(adresse, {
type: "svg", type: "svg",
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { CSV_BOM, csvZeile } from "@/lib/csv";
import { PROTOKOLL_CSV_KOPF, protokollCsvWerte, protokollFilterAus } from "@/lib/protokoll-filter";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { protokollStapelweise } from "@/server/queries/protokoll";
import { protokollieren } from "@/server/services/protokoll";
/**
* Protokoll als CSV (#027) mit denselben Filtern wie die Seite. Jede Rolle darf lesen; der
* Handler prueft das selbst.
*
* Gestreamt: die Datenbank liefert Stapel zu 500 Zeilen, jeder Stapel geht sofort hinaus.
* Ein Protokoll mit Hunderttausenden Eintraegen muss so nie ganz in den Speicher.
*/
export async function GET(anfrage: Request) {
let benutzer;
try {
({ benutzer } = await rechtErforderlich("lesen"));
} catch (fehler) {
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
throw fehler;
}
const filter = protokollFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
// Vor dem ersten Byte: wer das Protokoll mitnimmt, steht selbst darin – und scheitert das
// Schreiben, gibt es auch keinen Export.
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "protokoll.exportiert",
ergebnis: "ERFOLG",
details: { filter: Object.fromEntries(Object.entries(filter).filter(([, wert]) => wert)) },
});
const kodierer = new TextEncoder();
const stapel = protokollStapelweise(filter);
const strom = new ReadableStream<Uint8Array>({
start(steuerung) {
steuerung.enqueue(kodierer.encode(CSV_BOM + csvZeile(PROTOKOLL_CSV_KOPF) + "\r\n"));
},
// pull statt Schleife in start: der naechste Stapel wird erst gelesen, wenn der Client
// den vorigen abgenommen hat (Gegendruck).
async pull(steuerung) {
try {
const { value, done } = await stapel.next();
if (done) {
steuerung.close();
return;
}
steuerung.enqueue(
kodierer.encode(value.map((z) => csvZeile(protokollCsvWerte(z)) + "\r\n").join("")),
);
} catch (fehler) {
steuerung.error(fehler);
}
},
async cancel() {
await stapel.return(undefined);
},
});
const tag = new Date().toISOString().slice(0, 10);
return new NextResponse(strom, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="sshkv-protokoll-${tag}.csv"`,
"Cache-Control": "no-store",
},
});
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { matrixCsv, matrixFilterAus } from "@/lib/zuweisungsmatrix";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
/**
* Zuweisungsmatrix als CSV (#025) mit denselben Filtern wie die Seite. Jede Rolle darf lesen;
* der Handler prueft das selbst, der Proxy laesst ohne Cookie ohnehin niemanden durch.
*/
export async function GET(anfrage: Request) {
try {
await rechtErforderlich("lesen");
} catch (fehler) {
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
throw fehler;
}
const filter = matrixFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
const { matrix } = await zuweisungsMatrix(filter);
return new NextResponse(matrixCsv(matrix), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="sshkv-zuweisungen.csv"',
"Cache-Control": "no-store",
},
});
}
+1 -1
View File
@@ -22,7 +22,7 @@ export default async function BestaetigenSeite({
titel="Anmeldung bestätigen" titel="Anmeldung bestätigen"
text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten." text="Diese Aktion ist kritisch. Bitte bestätigen Sie Ihre Anmeldung – die Bestätigung gilt fünf Minuten."
> >
{benutzer.istNotfall ? ( {benutzer.passwortHash && !benutzer.oidcSub ? (
<BestaetigenFormular ziel={ziel} /> <BestaetigenFormular ziel={ziel} />
) : ( ) : (
<a <a
-89
View File
@@ -1,89 +0,0 @@
/**
* BEISPIELDATEN für den klickbaren Prototyp (#002) – keine echten Hosts oder Schlüssel.
* Die echten Lesemodelle kommen in #021 (Abgleich) und #026 (Übersicht); dann entfällt
* diese Datei. Alle Zeiten beziehen sich auf BEISPIEL_JETZT, damit der statisch gebaute
* Prototyp nicht mit jedem Build andere Relativangaben zeigt.
*/
import type { HostStatus } from "@/components/ui/Statuspunkt";
export const BEISPIEL_JETZT = "2026-10-12T07:00:00Z"; // 09:00 Berliner Zeit
export const letzterAbgleich = "2026-10-12T06:42:00Z";
export const kennzahlen = {
hosts: { wert: 42, hinweis: "38 synchron · 1 nicht erreichbar" },
schluessel: { wert: 117, hinweis: "davon 9 mit Private Key" },
abweichungen: { wert: 3, hinweis: "auf 3 Hosts" },
ablaufend: { wert: 5, hinweis: "in den nächsten 30 Tagen" },
};
export type Abweichung = {
host: string;
benutzer: string;
status: HostStatus;
befund: string;
};
export const abweichungen: Abweichung[] = [
{ host: "srv-web03.intern", benutzer: "root", status: "drift", befund: "1 fremder Schlüssel" },
{ host: "srv-db01.intern", benutzer: "deploy", status: "drift", befund: "Schlüssel fehlt" },
{ host: "fw-edge01.intern", benutzer: "sshkv", status: "drift", befund: "Optionen geändert" },
{ host: "srv-legacy02.intern", benutzer: "root", status: "unerreichbar", befund: "nicht erreichbar" },
];
export type AblaufEintrag = { zweck: string; person: string; typ: string; ablauf: string };
export const baldAblaufend: AblaufEintrag[] = [
{ zweck: "Laptop Florian", person: "Florian Wilde", typ: "ed25519", ablauf: "2026-10-16T10:00:00Z" },
{ zweck: "CI-Deployment Shop", person: "Dienst", typ: "ed25519", ablauf: "2026-10-21T10:00:00Z" },
{
zweck: "Backup srv-backup01 → srv-pve01",
person: "Dienst",
typ: "ed25519",
ablauf: "2026-10-30T10:00:00Z",
},
{ zweck: "Notebook Sandra", person: "Sandra Keller", typ: "rsa-4096", ablauf: "2026-11-04T10:00:00Z" },
{ zweck: "Monitoring Zabbix", person: "Dienst", typ: "ed25519", ablauf: "2026-11-09T10:00:00Z" },
];
export type AktionsArt = "ausgerollt" | "widerrufen" | "erstellt" | "abgleich" | "rotiert";
export type LetzteAktion = {
zeit: string;
art: AktionsArt;
beschreibung: string;
benutzer: string;
};
export const letzteAktionen: LetzteAktion[] = [
{
zeit: "2026-10-12T06:42:00Z",
art: "abgleich",
beschreibung: "Abgleich aller Hosts: 3 Abweichungen",
benutzer: "System",
},
{
zeit: "2026-10-12T06:15:00Z",
art: "ausgerollt",
beschreibung: "Public Key „Backup srv-backup01 → srv-pve01“ auf backup@srv-pve01",
benutzer: "Florian Wilde",
},
{
zeit: "2026-10-11T15:30:00Z",
art: "widerrufen",
beschreibung: "Schlüssel „Laptop alt Thomas“ von 6 Hosts entfernt",
benutzer: "Florian Wilde",
},
{
zeit: "2026-10-11T12:04:00Z",
art: "rotiert",
beschreibung: "CI-Deployment Shop durch neuen ed25519-Schlüssel ersetzt",
benutzer: "Sandra Keller",
},
{
zeit: "2026-10-11T09:20:00Z",
art: "erstellt",
beschreibung: "Schlüssel „Monitoring Zabbix“ aus Vorlage Deployment-Zugang",
benutzer: "Sandra Keller",
},
];
+139
View File
@@ -0,0 +1,139 @@
import { Badge } from "@/components/ui/Badge";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { datumZeit } from "@/lib/format";
import { abgleichAktion } from "@/server/actions/abgleich";
import type { IstZustand } from "@/server/queries/abgleich";
import { UebernehmenDialog } from "./UebernehmenDialog";
/** Abweichungen und Ist-Zustand eines Hosts (#021, #022). */
const ART_TEXT: Record<string, string> = {
UNBEKANNTER_SCHLUESSEL: "Unbekannter Schlüssel",
FEHLENDER_SCHLUESSEL: "Schlüssel fehlt",
ZUSAETZLICH_IM_BLOCK: "Block von Hand geändert",
DATEI_GEAENDERT: "Block beschädigt",
RECHTE: "Rechte",
HOSTKEY_GEAENDERT: "Host-Key geändert",
PRIVATE_KEY_FEHLT: "Private Key fehlt/verändert",
};
const HERKUNFT: Record<string, { text: string; variante: "ok" | "neutral" | "gefahr" }> = {
verwaltet: { text: "verwaltet", variante: "ok" },
bekannt: { text: "bekannt", variante: "neutral" },
fremd: { text: "unbekannt", variante: "gefahr" },
};
export function AbgleichKarten({
hostId,
zustand,
darfAbgleichen,
darfUebernehmen,
darfVerwalten,
}: {
hostId: string;
zustand: IstZustand;
darfAbgleichen: boolean;
darfUebernehmen: boolean;
darfVerwalten: boolean;
}) {
return (
<>
<Karte
titel="Abweichungen"
untertitel="Unterschiede zwischen Soll und Host beim letzten Abgleich"
aktionen={
darfAbgleichen ? (
<VorgangsKnopf aktion={abgleichAktion.bind(null, hostId)} beschriftung="Jetzt abgleichen" />
) : null
}
>
{zustand.abweichungen.length === 0 ? (
<p className="text-ok text-[14px]">● Keine offenen Abweichungen.</p>
) : (
<ul className="flex flex-col">
{zustand.abweichungen.map((a) => (
<li
key={a.id}
className="border-border flex flex-col gap-1 border-b py-3 first:pt-0 last:border-b-0 last:pb-0"
>
<span className="text-danger text-[14px] font-medium">
● {ART_TEXT[a.art] ?? a.art}
{a.zielbenutzer && (
<span className="text-muted font-mono text-[13px]"> · {a.zielbenutzer}</span>
)}
</span>
<span className="text-text text-[14px]">{a.text}</span>
<span className="text-muted font-mono text-[12px] break-all">
{a.fingerprint ? `${a.fingerprint} · ` : ""}seit {datumZeit(a.eroeffnetAm)}
</span>
</li>
))}
</ul>
)}
</Karte>
<Karte
titel="Ist-Zustand je Benutzer"
untertitel="Alle Schlüssel in authorized_keys – auch die, die sshkv nicht verwaltet"
>
{zustand.beobachtungen.length === 0 ? (
<Leerzustand
titel="Noch nicht abgeglichen"
text="Nach dem ersten Abgleich steht hier, was wirklich auf dem Host liegt."
/>
) : (
<div className="flex flex-col gap-6">
{zustand.beobachtungen.map((b) => (
<section key={b.zielbenutzer} className="flex flex-col gap-2">
<h3 className="text-text font-mono text-[14px] font-medium">
{b.zielbenutzer}{" "}
<span className="text-muted text-[12px]">· {datumZeit(b.beobachtetAm)}</span>
</h3>
{b.eintraege.length === 0 ? (
<p className="text-muted text-[13.5px]">Datei leer.</p>
) : (
<ul className="flex flex-col gap-2">
{b.eintraege.map((e) => {
const herkunft = HERKUNFT[e.herkunft]!;
return (
<li
key={`${e.fingerprint}-${e.imBlock}`}
className="border-border rounded-box flex flex-col gap-2 border p-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="font-mono text-[12.5px] break-all">
<span className="text-muted">{e.typ}</span> {e.fingerprint}
</p>
<p className="text-muted font-mono text-[12px] break-all">
{e.kommentar || "(ohne Kommentar)"}
{e.optionen ? ` · ${e.optionen}` : ""}
{e.imBlock ? " · im verwalteten Block" : ""}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variante={herkunft.variante}>{herkunft.text}</Badge>
{e.herkunft === "fremd" && darfUebernehmen && (
<UebernehmenDialog
hostId={hostId}
zielbenutzer={b.zielbenutzer}
fingerprint={e.fingerprint}
kommentar={e.kommentar}
personen={zustand.personen}
darfVerwalten={darfVerwalten}
/>
)}
</div>
</li>
);
})}
</ul>
)}
</section>
))}
</div>
)}
</Karte>
</>
);
}
@@ -0,0 +1,33 @@
"use client";
import { RefreshCw } from "lucide-react";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/Button";
import { alleAbgleichenAktion } from "@/server/actions/abgleich";
/** „Jetzt abgleichen“ auf der Uebersicht (#021): reiht fuer jeden Host einen Abgleich ein. */
export function AlleAbgleichenKnopf() {
const [meldung, setMeldung] = useState<string>();
const [laeuft, starten] = useTransition();
return (
<span className="inline-flex flex-col items-start gap-1">
<Button
icon={<RefreshCw className="size-4" />}
disabled={laeuft}
onClick={() =>
starten(async () => {
const antwort = await alleAbgleichenAktion();
setMeldung(antwort.fehler ?? `${antwort.anzahl ?? 0} Abgleich(e) eingereiht.`);
})
}
>
Jetzt abgleichen
</Button>
{meldung && (
<span role="status" className="text-muted text-[12.5px]">
{meldung}
</span>
)}
</span>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Ankreuzfeld, Button, Feld, Formularfehler } from "@/components/ui";
import { Auswahlliste } from "@/components/ui/Feld";
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
import { uebernehmenAktion } from "@/server/actions/abgleich";
/** Fremden Key aus dem Ist-Zustand uebernehmen (#022). */
export function UebernehmenDialog({
hostId,
zielbenutzer,
fingerprint,
kommentar,
personen,
darfVerwalten,
}: {
hostId: string;
zielbenutzer: string;
fingerprint: string;
kommentar: string;
personen: { id: string; name: string }[];
darfVerwalten: boolean;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [offen, setOffen] = useState(false);
const [verwalten, setVerwalten] = useState(darfVerwalten);
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
const ergebnis = await uebernehmenAktion(vorher, formular);
if (!ergebnis.fehler && !ergebnis.feldfehler) setOffen(false);
return ergebnis;
});
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (offen && !element.open) element.showModal();
if (!offen && element.open) element.close();
}, [offen]);
return (
<>
<Button klein onClick={() => setOffen(true)}>
Übernehmen
</Button>
<dialog
ref={dialog}
aria-label="Schlüssel übernehmen"
onClose={() => setOffen(false)}
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-lg border p-5 backdrop:bg-black/70 sm:p-6"
>
<h2 className="font-display mb-1 text-[18px] font-semibold">Schlüssel übernehmen</h2>
<p className="text-muted mb-4 font-mono text-[12.5px] break-all">
{zielbenutzer} · {fingerprint}
</p>
<form onSubmit={absenden} className="flex flex-col gap-4">
<input type="hidden" name="hostId" value={hostId} />
<input type="hidden" name="zielbenutzer" value={zielbenutzer} />
<input type="hidden" name="fingerprint" value={fingerprint} />
<Formularfehler text={zustand.fehler} />
<Feld beschriftung="Zweck / Name" name="name" defaultValue={kommentar} required />
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue="PERSON">
<option value="PERSON">Person</option>
<option value="DIENST">Dienst</option>
</Auswahlliste>
<Auswahlliste beschriftung="Person (optional)" name="personId" defaultValue="">
<option value="">– keine –</option>
{personen.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Auswahlliste>
{darfVerwalten && (
<Ankreuzfeld
name="verwalten"
value="ja"
checked={verwalten}
onChange={(e) => setVerwalten(e.target.checked)}
beschriftung="In den verwalteten Block holen"
erklaerung="Beim nächsten Ausrollen wandert die Zeile in den Block, die alte Zeile außerhalb wird entfernt – mit Dry-Run."
/>
)}
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Übernehme …" : "Übernehmen"}
</Button>
</form>
</dialog>
</>
);
}
@@ -2,11 +2,11 @@
import { useActionState } from "react"; import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui"; import { Button, Feld, Formularfehler } from "@/components/ui";
import { notfallBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung"; import { kontoBestaetigenAktion, type FormularZustand } from "@/server/actions/anmeldung";
/** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */ /** Erneute Bestaetigung des Notfall-Admins vor kritischen Aktionen (#004). */
export function BestaetigenFormular({ ziel }: { ziel: string }) { export function BestaetigenFormular({ ziel }: { ziel: string }) {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallBestaetigenAktion, {}); const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(kontoBestaetigenAktion, {});
return ( return (
<form action={aktion} className="flex flex-col gap-4"> <form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="ziel" value={ziel} /> <input type="hidden" name="ziel" value={ziel} />
@@ -2,11 +2,14 @@
import { useActionState } from "react"; import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui"; import { Button, Feld, Formularfehler } from "@/components/ui";
import { notfallAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung"; import { kontoAnmeldenAktion, type FormularZustand } from "@/server/actions/anmeldung";
/** Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code (#004). */ /**
export function NotfallFormular({ ziel }: { ziel: string }) { * Lokale Konten und Notfallzugang: Anmeldename, Passwort und – sobald eingerichtet – TOTP-Code
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(notfallAnmeldenAktion, {}); * (#004, #035).
*/
export function KontoFormular({ ziel, lokal }: { ziel: string; lokal: boolean }) {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(kontoAnmeldenAktion, {});
return ( return (
<form action={aktion} className="flex flex-col gap-4"> <form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="ziel" value={ziel} /> <input type="hidden" name="ziel" value={ziel} />
@@ -29,7 +32,7 @@ export function NotfallFormular({ ziel }: { ziel: string }) {
hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet." hinweis="Bei der ersten Anmeldung leer lassen – dann wird TOTP eingerichtet."
/> />
<Button type="submit" variante="sekundaer" disabled={laeuft}> <Button type="submit" variante="sekundaer" disabled={laeuft}>
{laeuft ? "Prüfe …" : "Mit Notfallzugang anmelden"} {laeuft ? "Prüfe …" : lokal ? "Mit Benutzername anmelden" : "Mit Notfallzugang anmelden"}
</Button> </Button>
</form> </form>
); );
@@ -0,0 +1,32 @@
"use client";
import { useActionState } from "react";
import { Button, Feld, Formularfehler } from "@/components/ui";
import { passwortSetzenAktion, type FormularZustand } from "@/server/actions/anmeldung";
export function PasswortSetzenFormular() {
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(passwortSetzenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<Formularfehler text={zustand.fehler} />
<Feld
beschriftung="Neues Passwort"
name="passwort"
type="password"
autoComplete="new-password"
required
autoFocus
/>
<Feld
beschriftung="Neues Passwort wiederholen"
name="wiederholung"
type="password"
autoComplete="new-password"
required
/>
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Speichere …" : "Passwort setzen"}
</Button>
</form>
);
}
+605
View File
@@ -0,0 +1,605 @@
"use client";
import { ArrowLeft, ArrowRight, ExternalLink, Play } from "lucide-react";
import { useState } from "react";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { warten } from "@/components/vorgang/warten";
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
import {
assistentErzeugenAktion,
assistentZielkontoAktion,
assistentZuweisenAktion,
type SchrittAntwort,
} from "@/server/actions/assistent";
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
import type { AssistentDaten } from "@/server/queries/assistent";
/**
* Host-zu-Host-Assistent (#020). Vier Schritte Eingabe, dann eine Zusammenfassung, dann die
* Ausfuehrung: Schluessel erzeugen → (Zielbenutzer anlegen) → zuweisen → Dry-Run und Ausrollen →
* Verbindungstest. Die Ausfuehrung ist wiederaufnehmbar: was schon erledigt ist, wird nicht
* wiederholt – wichtig, wenn mittendrin eine erneute Bestaetigung noetig wird.
*/
type Werte = {
vorlageId: string;
zweck: string;
ablaufAm: string;
quelleHostId: string;
quelleBenutzer: string;
pfad: string;
zielHostId: string;
zielBenutzer: string;
anlegen: boolean;
sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
sudoBefehle: string;
optionen: string;
quellIp: string;
alias: string;
hostName: string;
port: string;
knownHosts: boolean;
fingerprintGeprueft: boolean;
};
const LEER: Werte = {
vorlageId: "",
zweck: "",
ablaufAm: "",
quelleHostId: "",
quelleBenutzer: "root",
pfad: "",
zielHostId: "",
zielBenutzer: "root",
anlegen: false,
sudoModus: "KEINER",
sudoBefehle: "",
optionen: "",
quellIp: "",
alias: "",
hostName: "",
port: "22",
knownHosts: true,
fingerprintGeprueft: false,
};
const SCHRITTE = ["Vorlage & Zweck", "Quelle (Host A)", "Ziel (Host B)", "Prüfen", "Ausrollen"] as const;
type Ablauf = {
schluesselId?: string;
zielkontoFertig?: boolean;
privatVerwendungId?: string;
laeuft?: string;
fehler?: string;
bestaetigen?: boolean;
};
function datumIn(tage: number): string {
return new Date(Date.now() + tage * 86_400_000).toISOString().slice(0, 10);
}
function kurz(text: string): string {
return (
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 40) || "zugang"
);
}
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
const [schritt, setSchritt] = useState(0);
const [w, setW] = useState<Werte>(LEER);
const [ablauf, setAblauf] = useState<Ablauf>({});
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => setW((alt) => ({ ...alt, [feld]: wert }));
const vorlage = daten.vorlagen.find((v) => v.id === w.vorlageId);
const quelle = daten.hosts.find((h) => h.id === w.quelleHostId);
const ziel = daten.hosts.find((h) => h.id === w.zielHostId);
function vorlageWaehlen(id: string) {
const v = daten.vorlagen.find((x) => x.id === id);
setW((alt) => ({
...alt,
vorlageId: id,
...(v
? {
zielBenutzer: v.zielbenutzer,
anlegen: v.zielbenutzerAnlegen,
sudoModus: v.sudoModus,
sudoBefehle: v.sudoBefehle.join("\n"),
knownHosts: v.knownHostsEintragen,
ablaufAm: v.standardLaufzeitTage ? datumIn(v.standardLaufzeitTage) : "",
}
: {}),
}));
}
/** Beim Wechsel zu „Ziel“: Muster der Vorlage mit A und B aufloesen (nur leere Felder). */
function zielVorbelegen(zielId: string) {
const b = daten.hosts.find((h) => h.id === zielId);
const quellIp = w.quellIp || (quelle && istIpAdresse(quelle.adresse) ? quelle.adresse : "");
const werte = {
quellHost: quelle?.name,
quellHostIp: quellIp,
zielHost: b?.name,
zielHostIp: b?.adresse,
};
const aufloesen = (muster: string | null | undefined) => {
if (!muster) return "";
try {
return platzhalterErsetzen(muster, werte);
} catch {
return muster;
}
};
setW((alt) => ({
...alt,
zielHostId: zielId,
quellIp,
optionen: alt.optionen || aufloesen(vorlage?.authorizedKeysOptionen),
alias: alt.alias || aufloesen(vorlage?.sshConfigAlias) || (b ? b.name : ""),
hostName: b ? b.adresse : "",
port: b ? String(b.port) : "22",
pfad: alt.pfad || `~/.ssh/id_ed25519_${kurz(alt.zweck)}`,
fingerprintGeprueft: false,
}));
}
const offenePlatzhalter = /\{\{/.test(w.optionen) || /\{\{/.test(w.alias);
let sudoVorschau: string | null = null;
let sudoFehler: string | undefined;
if (w.anlegen) {
try {
sudoVorschau = sudoersInhalt(
w.zielBenutzer,
w.sudoModus,
w.sudoBefehle.split("\n").filter((z) => z.trim()),
);
} catch (fehler) {
sudoFehler = (fehler as Error).message;
}
}
const schrittOk = [
w.zweck.trim().length > 0,
Boolean(w.quelleHostId && w.quelleBenutzer),
Boolean(w.zielHostId && w.zielHostId !== w.quelleHostId && w.zielBenutzer && w.alias && w.hostName) &&
!offenePlatzhalter &&
!sudoFehler &&
(!w.knownHosts || w.fingerprintGeprueft),
true,
];
async function ausfuehren() {
let stand = { ...ablauf, fehler: undefined, bestaetigen: false } as Ablauf;
const melden = (neu: Partial<Ablauf>) => {
stand = { ...stand, ...neu };
setAblauf(stand);
};
const pruefen = (antwort: SchrittAntwort) => {
if (antwort.fehler || antwort.bestaetigen) {
melden({ laeuft: undefined, fehler: antwort.fehler, bestaetigen: Boolean(antwort.bestaetigen) });
return false;
}
return true;
};
try {
if (!stand.schluesselId) {
melden({ laeuft: "Schlüsselpaar wird erzeugt …" });
const antwort = await assistentErzeugenAktion({
name: w.zweck.trim(),
vorlageId: w.vorlageId || null,
ablaufAm: w.ablaufAm || null,
});
if (!pruefen(antwort)) return;
const ergebnis = await warten(antwort.vorgangId!);
if (ergebnis.status !== "ERLEDIGT")
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Erzeugen fehlgeschlagen." });
melden({ schluesselId: (ergebnis.ergebnis as { schluesselId: string }).schluesselId });
}
if (w.anlegen && !stand.zielkontoFertig) {
melden({ laeuft: `Benutzer ${w.zielBenutzer} wird auf ${ziel?.name} eingerichtet …` });
const antwort = await assistentZielkontoAktion({
hostId: w.zielHostId,
benutzer: w.zielBenutzer,
modus: w.sudoModus,
befehle: w.sudoBefehle.split("\n").filter((z) => z.trim()),
});
if (!pruefen(antwort)) return;
const ergebnis = await warten(antwort.vorgangId!);
if (ergebnis.status !== "ERLEDIGT")
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Zielkonto fehlgeschlagen." });
melden({ zielkontoFertig: true });
}
if (!stand.privatVerwendungId) {
melden({ laeuft: "Zuweisungen werden angelegt …" });
const antwort = await assistentZuweisenAktion({
schluesselId: stand.schluesselId!,
quelle: { hostId: w.quelleHostId, benutzer: w.quelleBenutzer, pfad: w.pfad },
ziel: { hostId: w.zielHostId, benutzer: w.zielBenutzer, optionen: w.optionen.trim() || null },
ssh: { alias: w.alias, hostName: w.hostName, port: Number(w.port), knownHosts: w.knownHosts },
});
if (!pruefen(antwort)) return;
melden({ privatVerwendungId: antwort.privatVerwendungId });
}
melden({ laeuft: undefined });
setSchritt(4);
} catch (fehler) {
melden({ laeuft: undefined, fehler: (fehler as Error).message });
}
}
return (
<div className="flex flex-col gap-6">
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} />
{schritt === 0 && (
<Karte
titel="Vorlage & Zweck"
untertitel="Eine Vorlage füllt Zielbenutzer, Einschränkungen und Ablauf vor."
>
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Vorlage"
name="vorlageId"
value={w.vorlageId}
onChange={(e) => vorlageWaehlen(e.target.value)}
>
<option value="">– ohne Vorlage –</option>
{daten.vorlagen.map((v) => (
<option key={v.id} value={v.id}>
{v.name}
</option>
))}
</Auswahlliste>
{vorlage?.beschreibung && <p className="text-muted text-[14px]">{vorlage.beschreibung}</p>}
<Feld
beschriftung="Zweck"
name="zweck"
value={w.zweck}
onChange={(e) => setze("zweck", e.target.value)}
placeholder="Backup srv-backup01 → srv-pve01"
required
/>
<Feld
beschriftung="Ablaufdatum (optional)"
name="ablaufAm"
type="date"
value={w.ablaufAm}
onChange={(e) => setze("ablaufAm", e.target.value)}
hinweis="Projektbezogene Zugänge sollten ablaufen."
/>
</div>
</Karte>
)}
{schritt === 1 && (
<Karte
titel="Quelle – Host A"
untertitel="Dieser Host bekommt den Private Key und meldet sich später an."
>
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Host A"
name="quelleHostId"
value={w.quelleHostId}
onChange={(e) => setze("quelleHostId", e.target.value)}
>
<option value="">Host wählen …</option>
{daten.hosts.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Auswahlliste>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Benutzer auf A"
name="quelleBenutzer"
value={w.quelleBenutzer}
onChange={(e) => setze("quelleBenutzer", e.target.value)}
mono
/>
<Feld
beschriftung="Pfad des Private Keys"
name="pfad"
value={w.pfad || `~/.ssh/id_ed25519_${kurz(w.zweck)}`}
onChange={(e) => setze("pfad", e.target.value)}
mono
/>
</div>
</div>
</Karte>
)}
{schritt === 2 && (
<Karte titel="Ziel – Host B" untertitel="Dieser Host nimmt den Public Key an.">
<div className="flex flex-col gap-4">
<Auswahlliste
beschriftung="Host B"
name="zielHostId"
value={w.zielHostId}
onChange={(e) => zielVorbelegen(e.target.value)}
>
<option value="">Host wählen …</option>
{daten.hosts
.filter((h) => h.id !== w.quelleHostId)
.map((h) => (
<option key={h.id} value={h.id}>
{h.name}
</option>
))}
</Auswahlliste>
<Feld
beschriftung="Benutzer auf B"
name="zielBenutzer"
value={w.zielBenutzer}
onChange={(e) => setze("zielBenutzer", e.target.value)}
mono
/>
{w.zielBenutzer === "root" && (
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[13.5px]">
▲ Zugang als root: Wer den Private Key auf A nutzt – etwa ein KI-Agent wie Claude Code –, hat
auf B uneingeschränkten Zugriff. Für Deployments ist ein eigener Benutzer wie „deploy“ besser.
</p>
)}
<Ankreuzfeld
beschriftung={`Benutzer „${w.zielBenutzer}“ auf B anlegen bzw. pflegen`}
erklaerung="Mit Home und ~/.ssh; optional mit sudoers-Datei. Verlangt eine frische Bestätigung."
checked={w.anlegen}
onChange={(e) => setze("anlegen", e.target.checked)}
/>
{w.anlegen && (
<div className="border-border flex flex-col gap-3 border-l-2 pl-4">
<Auswahlliste
beschriftung="sudo"
name="sudoModus"
value={w.sudoModus}
onChange={(e) => setze("sudoModus", e.target.value as Werte["sudoModus"])}
>
<option value="KEINER">keine sudo-Rechte</option>
<option value="BEFEHLE">bestimmte Befehle</option>
<option value="VOLL">voller sudo-Zugriff</option>
</Auswahlliste>
{w.sudoModus === "BEFEHLE" && (
<Textbereich
beschriftung="Erlaubte Befehle (einer je Zeile, absoluter Pfad)"
name="sudoBefehle"
value={w.sudoBefehle}
onChange={(e) => setze("sudoBefehle", e.target.value)}
rows={3}
/>
)}
{sudoFehler && <p className="text-danger text-[13px]">{sudoFehler}</p>}
{sudoVorschau && (
<pre className="border-border bg-bg rounded-box overflow-x-auto border p-3 font-mono text-[12.5px]">
{sudoVorschau}
</pre>
)}
</div>
)}
{quelle && !istIpAdresse(quelle.adresse) && (
<Feld
beschriftung={`IP von ${quelle.name} (für from="…")`}
name="quellIp"
value={w.quellIp}
onChange={(e) => setze("quellIp", e.target.value)}
mono
hinweis="Die Adresse, unter der B den Host A sieht."
/>
)}
<Feld
beschriftung="Einschränkungen in authorized_keys"
name="optionen"
value={w.optionen}
onChange={(e) => setze("optionen", e.target.value)}
mono
placeholder={
quelle && istIpAdresse(quelle.adresse) ? `from="${quelle.adresse}"` : 'from="10.0.0.5"'
}
hinweis={
offenePlatzhalter
? "Es sind noch Platzhalter offen."
: 'Empfohlen: from="<IP von A>", damit der Schlüssel nur von dort funktioniert.'
}
/>
<div className="grid gap-4 sm:grid-cols-3">
<Feld
beschriftung="Alias auf A"
name="alias"
value={w.alias}
onChange={(e) => setze("alias", e.target.value)}
mono
/>
<Feld
beschriftung="HostName (aus Sicht von A)"
name="hostName"
value={w.hostName}
onChange={(e) => setze("hostName", e.target.value)}
mono
/>
<Feld
beschriftung="Port"
name="port"
type="number"
value={w.port}
onChange={(e) => setze("port", e.target.value)}
mono
/>
</div>
<Ankreuzfeld
beschriftung="Host-Key von B in known_hosts von A eintragen"
checked={w.knownHosts}
onChange={(e) => setze("knownHosts", e.target.checked)}
/>
{w.knownHosts && ziel && (
<div className="border-border rounded-box flex flex-col gap-2 border p-3">
{ziel.hostKeys.map((k) => (
<p key={k.fingerprint} className="font-mono text-[12.5px] break-all">
<span className="text-muted">{k.algorithmus}</span> {k.fingerprint}
</p>
))}
<Ankreuzfeld
beschriftung="Fingerprint von B ist bestätigt"
erklaerung="Derselbe Host-Key, dem sshkv beim Bootstrap von B vertraut hat."
checked={w.fingerprintGeprueft}
onChange={(e) => setze("fingerprintGeprueft", e.target.checked)}
/>
</div>
)}
</div>
</Karte>
)}
{schritt === 3 && (
<Karte
titel="Zusammenfassung"
untertitel="Noch ist nichts passiert. Ausgerollt wird erst nach dem Dry-Run."
>
<dl className="grid gap-x-6 gap-y-3 text-[14px] sm:grid-cols-[12rem_minmax(0,1fr)]">
<dt className="text-muted">Schlüssel</dt>
<dd>
ed25519 · {w.zweck}
{w.ablaufAm && (
<span className="text-accent">
{" "}
· läuft ab am {w.ablaufAm.split("-").reverse().join(".")}
</span>
)}
</dd>
<dt className="text-muted">Private Key</dt>
<dd className="font-mono text-[13px] break-all">
{w.quelleBenutzer}@{quelle?.name}:{w.pfad}
</dd>
<dt className="text-muted">~/.ssh/config auf A</dt>
<dd className="font-mono text-[13px] break-all">
Host {w.alias} → {w.zielBenutzer}@{w.hostName}:{w.port}
</dd>
<dt className="text-muted">Public Key</dt>
<dd className="font-mono text-[13px] break-all">
{w.optionen && `${w.optionen} · `}
{w.zielBenutzer}@{ziel?.name}
</dd>
{w.anlegen && (
<>
<dt className="text-muted">Zielbenutzer</dt>
<dd>
„{w.zielBenutzer}“ auf {ziel?.name} anlegen,{" "}
{w.sudoModus === "KEINER" ? (
"ohne sudo"
) : w.sudoModus === "VOLL" ? (
<span className="text-danger">voller sudo-Zugriff</span>
) : (
"sudo für bestimmte Befehle"
)}
</dd>
</>
)}
</dl>
{w.anlegen && !bestaetigt && !ablauf.bestaetigen && (
<p className="text-accent mt-4 text-[13.5px]">
Das Anlegen des Benutzers verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
Ausführen danach.
</p>
)}
<div className="border-border mt-5 flex flex-col gap-3 border-t pt-4">
<Formularfehler text={ablauf.fehler} />
{ablauf.bestaetigen && (
<a
href="/bestaetigen?ziel=%2Fassistent%2Fbestaetigt"
target="_blank"
rel="noopener"
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
>
<ExternalLink className="size-4" aria-hidden /> Anmeldung in neuem Tab bestätigen, dann hier
erneut ausführen
</a>
)}
{ablauf.laeuft && (
<p role="status" className="text-muted text-[14px]">
{ablauf.laeuft}
</p>
)}
<Button
variante="primaer"
icon={<Play className="size-4" />}
disabled={Boolean(ablauf.laeuft)}
onClick={() => void ausfuehren()}
>
{ablauf.schluesselId ? "Fortsetzen" : "Vorbereiten"}
</Button>
</div>
</Karte>
)}
{schritt === 4 && ablauf.schluesselId && (
<>
<Karte
titel="Dry-Run und Ausrollen"
untertitel="Prüfen Sie die Änderungen auf A und B, dann anwenden."
>
<AusrollenPanel auswahl={{ schluesselId: ablauf.schluesselId }} offen={2} />
</Karte>
<Karte
titel="Verbindung testen"
untertitel={`Auf ${quelle?.name} als ${w.quelleBenutzer}: ssh ${w.alias} true`}
>
{ablauf.privatVerwendungId && (
<VorgangsKnopf
aktion={verbindungTestenAktion.bind(null, ablauf.privatVerwendungId)}
beschriftung="Verbindung von A nach B testen"
variante="primaer"
/>
)}
<p className="text-muted mt-3 text-[13.5px]">
Zum Schlüssel:{" "}
<a className="text-accent" href={`/schluessel/${ablauf.schluesselId}`}>
{w.zweck}
</a>
</p>
</Karte>
</>
)}
{schritt < 3 && (
<div className="flex flex-wrap justify-between gap-3">
<Button
icon={<ArrowLeft className="size-4" />}
disabled={schritt === 0}
onClick={() => setSchritt(schritt - 1)}
>
Zurück
</Button>
<Button
variante="primaer"
icon={<ArrowRight className="size-4" />}
disabled={!schrittOk[schritt]}
onClick={() => {
if (schritt === 1 && !w.pfad) setze("pfad", `~/.ssh/id_ed25519_${kurz(w.zweck)}`);
setSchritt(schritt + 1);
}}
>
Weiter
</Button>
</div>
)}
{schritt === 3 && !ablauf.schluesselId && (
<div>
<Button
icon={<ArrowLeft className="size-4" />}
disabled={Boolean(ablauf.laeuft)}
onClick={() => setSchritt(2)}
>
Zurück
</Button>
</div>
)}
</div>
);
}
+29 -6
View File
@@ -2,7 +2,7 @@
import { Play, ScanSearch } from "lucide-react"; import { Play, ScanSearch } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useRef, useState, useTransition } from "react";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { DiffBox } from "@/components/ui/DiffBox"; import { DiffBox } from "@/components/ui/DiffBox";
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang"; import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
@@ -34,7 +34,16 @@ type PlanZiel = {
fehler?: string; fehler?: string;
}; };
export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; offen: number }) { export function AusrollenPanel({
auswahl,
offen,
nachAusrollen,
}: {
auswahl: AusrollAuswahl;
offen: number;
/** Nach erfolgreichem Ausrollen – etwa um einen Widerruf abzuschliessen (#024). */
nachAusrollen?: () => void;
}) {
const router = useRouter(); const router = useRouter();
const [trockenlaufId, setTrockenlaufId] = useState<string>(); const [trockenlaufId, setTrockenlaufId] = useState<string>();
const [ausrollenId, setAusrollenId] = useState<string>(); const [ausrollenId, setAusrollenId] = useState<string>();
@@ -43,12 +52,22 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
const trockenlauf = useVorgang(trockenlaufId); const trockenlauf = useVorgang(trockenlaufId);
const ausrollen = useVorgang(ausrollenId); const ausrollen = useVorgang(ausrollenId);
// Ueber eine Ref, damit der Rueckruf genau einmal je Lauf feuert und nicht bei jedem Render.
const rueckruf = useRef(nachAusrollen);
useEffect(() => { useEffect(() => {
if (ausrollen.stand && !laeuftNoch(ausrollen.stand)) router.refresh(); rueckruf.current = nachAusrollen;
});
useEffect(() => {
if (!ausrollen.stand || laeuftNoch(ausrollen.stand)) return;
router.refresh();
if (ausrollen.stand.status === "ERLEDIGT") rueckruf.current?.();
}, [ausrollen.stand, router]); }, [ausrollen.stand, router]);
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele; const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0; const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0;
// Steht schon alles so auf dem Host (etwa ein Key, den jemand von Hand entfernt hat), gibt es
// nichts zu schreiben – der Zustand der Zuweisungen muss trotzdem nachgezogen werden (#024).
const nurZustand = aenderungen === 0 && offen > 0 && (plan?.some((z) => !z.fehler) ?? false);
const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand); const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand);
const fertig = ausrollen.stand?.status === "ERLEDIGT"; const fertig = ausrollen.stand?.status === "ERLEDIGT";
@@ -103,7 +122,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
</div> </div>
))} ))}
{plan && aenderungen > 0 && !fertig && ( {plan && (aenderungen > 0 || nurZustand) && !fertig && (
<div className="border-border flex flex-col gap-3 border-t pt-4"> <div className="border-border flex flex-col gap-3 border-t pt-4">
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]"> <label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
<input type="checkbox" checked disabled className="accent-accent size-5" /> <input type="checkbox" checked disabled className="accent-accent size-5" />
@@ -124,7 +143,11 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
}) })
} }
> >
{laeuftNoch(ausrollen.stand) ? "Wird ausgerollt …" : `Änderung anwenden (${aenderungen})`} {laeuftNoch(ausrollen.stand)
? "Wird ausgerollt …"
: nurZustand
? "Zustand übernehmen"
: `Änderung anwenden (${aenderungen})`}
</Button> </Button>
<Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}> <Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
Verwerfen Verwerfen
@@ -132,7 +155,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
</div> </div>
</div> </div>
)} )}
{plan && aenderungen === 0 && ( {plan && aenderungen === 0 && !fertig && (
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p> <p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
)} )}
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && ( {ausrollen.stand?.status === "FEHLGESCHLAGEN" && (

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