Compare commits
@@ -59,6 +59,25 @@ SSHKV_BREAKGLASS_PASSWORD=
|
||||
# --- Worker --------------------------------------------------------------------
|
||||
# Zeitplan fuer den Abgleich aller Hosts (cron)
|
||||
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) ---------------------------------------------------
|
||||
# SSH-Testziele aus docker-compose.test.yml. Die Compose-Datei liest dieselben Variablen
|
||||
@@ -70,3 +89,8 @@ SSHKV_TEST_ROOT_PASSWORT=sshkv-test
|
||||
SSHKV_TEST_TEST_PASSWORT=sshkv-test
|
||||
# Mock-OIDC (Port-Bindung)
|
||||
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
|
||||
|
||||
@@ -8,13 +8,57 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
## [Unveröffentlicht]
|
||||
|
||||
### 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)
|
||||
|
||||
### Geändert
|
||||
- Protokoll-IDs werden lückenlos vergeben (#032)
|
||||
|
||||
### Behoben
|
||||
- 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
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt (Wandzeit):** 2 h 55 min
|
||||
**Summe der Issue-Zeiten:** 8 h 23 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014 und #019 (Oberfläche).
|
||||
**Gesamt (Wandzeit):** 5 h 50 min
|
||||
**Summe der Issue-Zeiten:** 15 h 43 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032.
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -28,12 +28,29 @@
|
||||
| #018 Zielbenutzer und sudoers | 0 h 30 min |
|
||||
| #019 Vorlagen | 0 h 20 min |
|
||||
| #020 Host-zu-Host-Assistent | 0 h 50 min |
|
||||
| #021 Abgleich und Drift-Erkennung | 0 h 45 min |
|
||||
| #022 Import bestehender Schlüssel | 0 h 20 min |
|
||||
| #023 Geführte Rotation | 0 h 20 min |
|
||||
| #024 Widerruf und Massenwiderruf | 0 h 25 min |
|
||||
| #025 Zuweisungsmatrix | 0 h 30 min |
|
||||
| #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 |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
| v0.1.0 | 1 h 25 min (Wandzeit) | **abgeschlossen**
|
||||
| v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen**
|
||||
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
||||
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||
| v1.0.0 | 1 h 25 min (Wandzeit) | in Arbeit – Abnahme steht aus
|
||||
|
||||
---
|
||||
|
||||
@@ -101,3 +118,70 @@
|
||||
- **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).
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
> Hostgruppen, Host-Key-Prüfung (TOFU) und Bootstrap neuer Hosts. Schlüsselverwaltung und
|
||||
> Ausrollen folgen in Phase 2 und 3 – siehe [`docs/PLAN.md`](docs/PLAN.md).
|
||||
> **Stand 0.5.0:** alle fünf Phasen aus [`docs/PLAN.md`](docs/PLAN.md) umgesetzt – Hosts mit Host-Key-Prüfung
|
||||
> und Bootstrap, Schlüssel, Ausrollen von Public und Private Keys mit Dry-Run, Host-zu-Host-Assistent,
|
||||
> Abgleich, Rotation, Widerruf, Zuweisungsmatrix, Audit-Log, Benachrichtigungen. 1.0.0 nach der Abnahme.
|
||||
|
||||
## 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);
|
||||
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)
|
||||
|
||||
|
||||
@@ -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."
|
||||
@@ -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")."
|
||||
@@ -64,6 +64,15 @@ services:
|
||||
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
|
||||
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
||||
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
||||
# 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
|
||||
# Private Keys. Nach aussen braucht er nur den OIDC-Anbieter.
|
||||
ports:
|
||||
@@ -94,9 +103,19 @@ services:
|
||||
SSHKV_APP_SECRET: ${SSHKV_APP_SECRET}
|
||||
SSHKV_MASTER_KEY_FILE: /run/secrets/master_key
|
||||
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:
|
||||
- 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]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"]
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# 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
|
||||
|
||||

|
||||
|
||||
- **Mit authentik anmelden** – der Normalfall. Ihre Rolle (Leser, Operator, Admin) kommt aus den
|
||||
authentik-Gruppen und wird bei jeder Anmeldung neu bestimmt.
|
||||
- **Notfallzugang** – nur, wenn authentik ausfällt. Anmeldename, Passwort und ein Code aus einer
|
||||
Authenticator-App. Bei der ersten Anmeldung richtet der Notfall-Admin den zweiten Faktor ein
|
||||
(QR-Code scannen, Code bestätigen).
|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
```
|
||||
|
||||

|
||||
|
||||
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“.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
„Ä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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
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“.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
**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).
|
||||
|
||||

|
||||
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
- **Zuweisungen:** Matrix Schlüssel × Host mit Filtern; ein Klick auf eine Zelle zeigt die
|
||||
Zuweisungen und erlaubt neue. Export als CSV.
|
||||
|
||||

|
||||
|
||||
## 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“.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
- **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
|
||||
|
||||
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“).
|
||||
|
||||

|
||||
|
||||
|
After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 481 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -0,0 +1,130 @@
|
||||
# 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).
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,113 @@
|
||||
# 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 |
|
||||
|
||||
## 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`) |
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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** nur mit Passwort **und** TOTP; Rate-Limit gegen Raten.
|
||||
- **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.
|
||||
@@ -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.
|
||||
@@ -1,20 +1,27 @@
|
||||
# #021 Abgleich und Drift-Erkennung
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.4.0
|
||||
**Labels:** backend
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 45 min
|
||||
|
||||
## Ziel
|
||||
Periodischer und manueller Abgleich, Befunde je Host/Benutzer.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Zeitplan konfigurierbar
|
||||
- [ ] Befundarten
|
||||
- [x] Zeitplan konfigurierbar
|
||||
- [x] Befundarten
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Integrationstest
|
||||
- [x] Integrationstest
|
||||
|
||||
## Abhängigkeiten
|
||||
#016
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Ein SSH-Aufruf je Host** liest `authorized_keys` aller Benutzer mit UID 0 oder ≥ 1000 plus aller Benutzer mit Zuweisungen – als jeweiliger Benutzer, base64-kodiert (Zeilenenden und fremde Bytes kommen unversehrt an). Dazu Rechte und Eigentümer von `~/.ssh` und Datei.
|
||||
- **Befunde** (rein, `src/lib/ssh/abgleich.ts`): fehlender Key im Block, Block von Hand geändert (zusätzlicher Key oder andere Optionen), unbekannter Key außerhalb (bekannte Keys außerhalb sind kein Befund), Rechte (für Gruppe/Welt beschreibbar oder fremder Eigentümer), beschädigter Block, Private Key fehlt oder verändert (Hash gegen den versiegelten Key).
|
||||
- **Abweichungen** werden geöffnet und – sobald behoben – geschlossen, nie doppelt. Host-Key-Abweichungen bleiben bei #009.
|
||||
- **Zustände:** Public-Verwendungen AUSGEROLLT ↔ ABWEICHUNG, GEPLANT bleibt bis zum Ausrollen; Host SYNCHRON/DRIFT, letzter Abgleich, erreichbar.
|
||||
- **Zeitplan** über pg-boss (`SSHKV_SYNC_CRON`, Standard `30 2 * * *`, Europe/Berlin) – liegt in der Datenbank, feuert also auch bei mehreren Workern nur einmal. Hosts mit laufendem Abgleich werden übersprungen.
|
||||
- **Ändert nichts auf dem Host.** Korrekturen laufen über Dry-Run und Ausrollen.
|
||||
- `DATEI_GEAENDERT` meldet nur einen beschädigten Block; eine bloße Hash-Änderung ist kein Befund, weil sshkv selbst schreibt.
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
# #022 Import bestehender Schlüssel
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.4.0
|
||||
**Labels:** backend, ui
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
|
||||
|
||||
## Ziel
|
||||
Unbekannte Schlüssel eines Hosts übernehmen und zuordnen.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] wird zu Beginn der Phase verfeinert
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] wird zu Beginn der Phase verfeinert
|
||||
|
||||
## Abhängigkeiten
|
||||
#021
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Grundlage ist die letzte Beobachtung** des Abgleichs: Übernommen wird nur, was sshkv dort wirklich gesehen hat.
|
||||
- **Zwei Wege:** „bekannt“ (nur als Schlüssel aufnehmen – der Befund verschwindet, auf dem Host ändert sich nichts) oder „verwalten“ (Zuweisung mit `ausserhalbEntfernen`: beim nächsten Ausrollen wandert die Zeile mit ihren Optionen in den Block, die alte Zeile außerhalb verschwindet – mit Dry-Run).
|
||||
- Neues Feld `PublicVerwendung.ausserhalbEntfernen` (Migration `20261012131000`). Ohne dieses Feld fasst sshkv Zeilen außerhalb des Blocks weiterhin nie an.
|
||||
- Optionen der Zeile werden mit `optionenPruefen` übernommen; unbekannte Optionen verhindern das Verwalten mit klarer Meldung.
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
# #023 Geführte Rotation
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.4.0
|
||||
**Labels:** backend, ui
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 20 min
|
||||
|
||||
## Ziel
|
||||
Neuer Schlüssel an allen Verwendungen, Test, alter entfernt.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] „Rotieren“ auf der Schlüsselseite, Schrittfolge `/schluessel/[id]/rotieren` (wiederaufnehmbar)
|
||||
- [x] Neuer Schlüssel gleichen Typs, Name „… (rotiert TT.MM.JJJJ)“, `vorgaengerId` = alter
|
||||
- [x] Zuweisungen übernehmen: Public gleiche Ziele/Optionen, Private gleicher Host/Benutzer/Alias mit neuem Pfad
|
||||
- [x] Dry-Run + Ausrollen, Verbindungstests, erst dann den alten widerrufen (#024) → `ROTIERT`
|
||||
- [x] Personen-Key ohne Private Key: nur Hinweis „neuer Public Key nötig“
|
||||
- [x] Rotationskette auf der Schlüsselseite (Vorgänger/Nachfolger verlinkt)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] DB-Tests: Schritte, Reihenfolge, Rechte, Namen/Pfade (`tests/rotation.test.ts`)
|
||||
- [x] Integrationstest: neuer Schlüssel funktioniert, alter von beiden Hosts weg, `ssh <alias>` geht weiter
|
||||
- [x] E2E im Browser (`tests/e2e/rotation.spec.ts`), Screenshot `rotation-desktop.png`
|
||||
|
||||
## Abhängigkeiten
|
||||
#020
|
||||
#020, #024
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Fünf Schritte:** Neuer Schlüssel → Zuweisungen übernehmen → Dry-Run & Ausrollen → Verbindungstests →
|
||||
Alten widerrufen. Der aktuelle Schritt wird aus der Datenbank errechnet (`rotationsStand`), deshalb
|
||||
lässt sich die Rotation jederzeit neu laden und fortsetzen; jeder Schritt ist idempotent.
|
||||
- **Kette sofort:** Der Worker setzt `vorgaengerId` schon beim Erzeugen (neues Feld in der
|
||||
Vorgangs-Eingabe) – auch ein abgebrochener Browser hinterlässt keinen verwaisten Schlüssel.
|
||||
`vorgaengerId` ist eindeutig: zwei gleichzeitige Rotationen scheitern verständlich.
|
||||
- **Gleicher Alias, zwei Zuweisungen:** `privat.ts` schreibt je Alias genau einen config-Eintrag, die
|
||||
jüngste Zuweisung gewinnt (an der Position der ersten). So zeigt `ssh <alias>` nach dem Ausrollen auf
|
||||
die neue Datei, während die alte bis zum Widerruf daneben liegt.
|
||||
- **Neuer Pfad:** `<alter Pfad ohne -rJJJJMMTT>-rJJJJMMTT`, bei Kollision mit laufender Nummer –
|
||||
auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index über alle Zustände).
|
||||
- **Grün heißt aktuell:** Der alte Schlüssel wird nur widerrufen, wenn für jede Private-Zuweisung mit
|
||||
Alias ein erfolgreicher `VERBINDUNGSTEST` nach dem letzten Ausrollen vorliegt (Server prüft das).
|
||||
Ohne Private-Zuweisung mit Alias entfällt der Test.
|
||||
- **Abschluss:** Widerruf aus #024 ohne Zielkonten (der Nachfolger nutzt sie weiter); ein aktiver
|
||||
Nachfolger macht aus `WIDERRUFEN` den Status `ROTIERT`.
|
||||
- Ablaufdatum: Der Nachfolger erbt es nur, wenn es in der Zukunft liegt. Eine Passphrase wird nicht
|
||||
übernommen (sshkv erzeugt ohne).
|
||||
- Rechte: `schluesselVerwalten` + `publicKeyAusrollen`, bei Private-Zuweisungen `privateKeyAusrollen`.
|
||||
Deployment-Key: eigene Rotation (später).
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
# #024 Widerruf und Massenwiderruf
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.4.0
|
||||
**Labels:** backend, ui, sicherheit
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
|
||||
|
||||
## Ziel
|
||||
Ein-Klick-Widerruf inkl. Benutzer/sudoers; Massenwiderruf mit Re-Auth.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] „Widerrufen“ auf der Schlüsselseite mit Dialog, der jede Entfernung auflistet
|
||||
- [x] Public- und Private-Verwendungen → ENTFERNEN, Dry-Run, anwenden, erst dann `WIDERRUFEN`
|
||||
- [x] Zielkonten optional entfernen (nur `vonSshkvAngelegt`, nie ein noch genutztes)
|
||||
- [x] Massenwiderruf auf der Schlüsselliste, ein gemeinsamer Dry-Run, Protokoll je Schlüssel
|
||||
- [x] Abgelaufene Schlüssel erkennbar (Filter) und Widerruf anbieten – ohne automatisches Entfernen
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] DB-Tests: Zustände, Rechte, Zielkonten, Protokoll ohne Geheimnisse (`tests/widerruf.test.ts`)
|
||||
- [x] Integrationstest gegen zwei Hosts inkl. „Host nicht erreichbar“ (`tests/integration/widerruf.test.ts`)
|
||||
- [x] E2E im Browser (`tests/e2e/widerruf.spec.ts`), Screenshot `widerruf-desktop.png`
|
||||
|
||||
## Abhängigkeiten
|
||||
#018
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Zwischenzustand statt Sofort-Status:** neues Feld `Schluessel.widerrufEingeleitetAm` (Migration
|
||||
`20261012130000_widerruf_eingeleitet`). Der Status bleibt AKTIV/ABGELAUFEN, bis keine Verwendung mehr
|
||||
offen ist; erst `widerrufAbschliessen` setzt `WIDERRUFEN` (bzw. `ROTIERT`, wenn ein aktiver Nachfolger
|
||||
existiert, #023). Schlüsselseite und Liste zeigen „Widerruf unvollständig“ und die offenen Ziele.
|
||||
- **Reihenfolge:** vormerken → Dry-Run/Anwenden (bestehende Hash-Sperre aus #016/#017) → optional
|
||||
Zielkonten (`ZIELKONTO_ENTFERNEN`, erst wenn die Schlüssel überall weg sind) → abschließen.
|
||||
Ein Ziel, das im Dry-Run scheitert (nicht erreichbar), wird nicht angewendet; seine Verwendung bleibt
|
||||
ENTFERNEN, ein neuer Dry-Run versucht es erneut. Der Abschluss läuft nach erfolgreichem Anwenden
|
||||
automatisch und meldet, was noch fehlt.
|
||||
- **Nichts zu schreiben:** Steht der Schlüssel schon nirgends mehr, bietet das Dry-Run-Panel
|
||||
„Zustand übernehmen“ an (sonst bliebe der Widerruf hängen).
|
||||
- **Archivierte Hosts:** sshkv verbindet sich dort nicht mehr – deren Verwendungen gelten sofort als
|
||||
entfernt; der Dialog sagt das, das Protokoll nennt die Hosts.
|
||||
- **Zielkonten:** Ein Zielkonto kennt seinen Schlüssel nicht; Kandidat ist ein von sshkv angelegtes Konto
|
||||
auf (Host, Benutzer) einer Zuweisung der widerrufenen Schlüssel. Nutzt ein anderer, nicht im Widerruf
|
||||
befindlicher Schlüssel das Konto noch, bleibt es (Dialog nennt den Schlüssel).
|
||||
- **Rechte:** nur Public Keys → `publicKeyAusrollen`; Private Keys → `privateKeyAusrollen` (Admin);
|
||||
mehrere Schlüssel → `massenwiderruf`, Zielkonten → `zielkontenVerwalten` (jeweils Admin mit frischer
|
||||
Bestätigung). Der Deployment-Key ist nicht widerrufbar. Ein Schlüssel im Widerruf lässt sich nicht
|
||||
mehr zuweisen.
|
||||
- **Ablauf:** Status-Filter „abgelaufen“ umfasst aktive Schlüssel mit vergangenem Ablaufdatum; die
|
||||
Schlüsselseite bietet dann „Jetzt widerrufen“. Entfernt wird nie automatisch.
|
||||
- Massenwiderruf als GET-Formular `/schluessel/widerruf?id=…` – die Auswahl der Zielkonten reist als
|
||||
`?zielkonten=` mit und übersteht Neuladen und erneute Bestätigung.
|
||||
|
||||
@@ -1,19 +1,46 @@
|
||||
# #025 Zuweisungsmatrix
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.4.0
|
||||
**Labels:** ui
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0,5 h
|
||||
|
||||
## Ziel
|
||||
Matrix Schlüssel × Host.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] `/zuweisungen` ersetzt die Platzhalterseite: Matrix aktive Schlüssel (Zeilen) × nicht archivierte Hosts (Spalten), jede Rolle darf lesen
|
||||
- [x] Zelle: „P“ direkt, „P·G“ über Hostgruppe, „K“ Private Key; Zustand als Punkt + Kurztext, voller Text in `title` und für Screenreader; Zielbenutzer mono
|
||||
- [x] Filter per GET: Hostgruppe, Kategorie, Person, Suche über Schlüssel- und Hostnamen, „nur Zeilen/Spalten mit Zuweisungen“
|
||||
- [x] Eigener Scrollbereich mit sticky Kopfzeile und erster Spalte; unter `md` eine Liste je Schlüssel
|
||||
- [x] Zell-Dialog: Verwendungen mit Zustand, Optionen/Pfad, Links zu Schlüssel und Host; „Public Key zuweisen“ (vorhandenes Formular mit Vorauswahl) und „Entfernen“ für `publicKeyAusrollen`; Link „Zum Dry-Run“
|
||||
- [x] Kopf: Zähler (Schlüssel, Hosts, Zuweisungen, nicht synchron) und CSV-Export `/api/zuweisungen.csv`
|
||||
- [x] Reine Matrix-Funktion in `src/lib/zuweisungsmatrix.ts`, CSV-Helfer in `src/lib/csv.ts`, Query in `src/server/queries/zuweisungen.ts`
|
||||
- [x] Tests: `tests/zuweisungsmatrix.test.ts` (rein), `tests/zuweisungen.test.ts` (Query + CSV-Route), E2E `tests/e2e/zuweisungen.spec.ts`
|
||||
- [x] Screenshots `zuweisungen-desktop.png` / `zuweisungen-mobil.png`
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] Gruppen-Zuweisungen erscheinen auf jedem Mitglied und sind von direkten unterscheidbar
|
||||
- [x] Zustand nie nur über Farbe
|
||||
- [x] Seite scrollt bei 390 px nicht horizontal (Screenshot-Skript prüft das)
|
||||
- [x] Leser sehen die Matrix, aber weder Zuweisen noch Entfernen
|
||||
- [x] CSV mit den Spalten Schlüssel, Fingerprint, Host, Zielbenutzer, Art, Über Gruppe, Optionen, Zustand; Formelbeginne `= + - @` mit `'` entschärft
|
||||
- [x] Route Handler prüft `rechtErforderlich("lesen")` selbst (`tests/zugang.test.ts` grün)
|
||||
|
||||
## Abhängigkeiten
|
||||
#016
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- Die Matrix plant nur: Zuweisen und Entfernen nutzen die vorhandenen Aktionen, ausgerollt wird
|
||||
weiter über den Dry-Run der Schlüsselseite (`AusrollenPanel`).
|
||||
- Suche: trifft der Begriff Schlüssel, werden die Zeilen eingeschränkt; trifft er Hosts, die
|
||||
Spalten; trifft er beides, beides. So zeigt „srv-pve“ alle Schlüssel auf diesen Hosts statt
|
||||
einer leeren Matrix.
|
||||
- Zähler „Zuweisungen“ zählt Zelleneinträge, eine Gruppen-Zuweisung also einmal je Mitglied –
|
||||
dasselbe, was der CSV-Export als Zeilen liefert. „Nicht synchron“ ist alles außer `AUSGEROLLT`.
|
||||
- Zellenzustand ist der dringendste der Einträge (Fehler > Drift > entfernen > geplant > synchron).
|
||||
- CSV: Semikolon, UTF-8-BOM und CRLF für ein deutsches Excel; bei Private Keys steht der Pfad
|
||||
als „Pfad: …“ in der Spalte Optionen. Der Export nimmt dieselben Filter wie die Seite.
|
||||
- Deployment-Keys erscheinen als Zeilen, sind aber nicht zuweisbar (verwaltet sshkv selbst).
|
||||
- Entfernen einer Gruppen-Zuweisung aus einer Zelle gilt für die ganze Gruppe; der Dialog sagt das.
|
||||
- `PublicZuweisenFormular` hat eine optionale `vorauswahl`; `VerwendungsZustand` exportiert seine
|
||||
Darstellungstabelle (`ZUSTAND_ANZEIGE`), damit die Zelle dieselben Punkte nutzt.
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
# #026 Übersicht (Dashboard)
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.5.0
|
||||
**Labels:** ui
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 25 min
|
||||
|
||||
## Ziel
|
||||
Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen aus der Datenbank
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen aus der Datenbank
|
||||
|
||||
## Abhängigkeiten
|
||||
#021
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -1,19 +1,40 @@
|
||||
# #027 Protokollansicht, CSV-Export, Syslog
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.5.0
|
||||
**Labels:** ui, backend
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
|
||||
|
||||
## Ziel
|
||||
Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog.
|
||||
|
||||
## 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
|
||||
- [ ] 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
|
||||
#005
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -1,19 +1,65 @@
|
||||
# #028 Benachrichtigungen
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.5.0
|
||||
**Labels:** backend
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0,5 h
|
||||
|
||||
## Ziel
|
||||
E-Mail (M365 SMTP) und Webhook bei Drift und Ablauf.
|
||||
|
||||
## 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
|
||||
- [ ] 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
|
||||
#021
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
# #029 Handbuch und Betriebsdokumentation
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.5.0
|
||||
**Labels:** docs
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
|
||||
|
||||
## Ziel
|
||||
Installation, Bootstrap, Kern-Anwendungsfall mit Bildern; Bedrohungsanalyse, Backup-Konzept.
|
||||
|
||||
## 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
|
||||
- [ ] 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
|
||||
–
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
# #030 Issues nach Gitea übertragen
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** chore
|
||||
**Geschätzt:** 1 h **Tatsächlich:** –
|
||||
**Geschätzt:** 1 h **Tatsächlich:** 0 h 10 min
|
||||
|
||||
## Ziel
|
||||
Issue-Dateien, Labels und Meilensteine in Gitea anlegen (wartet auf Zugangsdaten).
|
||||
|
||||
## Aufgaben
|
||||
- [ ] `scripts/gitea-issues.ts`
|
||||
- [x] `scripts/gitea-issues.ts`
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] wird zu Beginn der Phase verfeinert
|
||||
|
||||
## Abhängigkeiten
|
||||
–
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sshkv",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "SSHKeyVault – zentrale Verwaltung von SSH-Schluesseln mit Weboberflaeche",
|
||||
@@ -56,6 +56,7 @@
|
||||
"jose": "^6.2.12",
|
||||
"lucide-react": "1.47.0",
|
||||
"next": "16.3.6",
|
||||
"nodemailer": "10.0.10",
|
||||
"openid-client": "^6.8.8",
|
||||
"pg": "^8.23.1",
|
||||
"pg-boss": "^12.37.1",
|
||||
@@ -71,6 +72,7 @@
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/node": "^24.19.2",
|
||||
"@types/nodemailer": "8.0.2",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.3.0",
|
||||
|
||||
@@ -199,6 +199,9 @@ importers:
|
||||
next:
|
||||
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)
|
||||
nodemailer:
|
||||
specifier: 10.0.10
|
||||
version: 10.0.10
|
||||
openid-client:
|
||||
specifier: ^6.8.8
|
||||
version: 6.8.8
|
||||
@@ -239,6 +242,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^24.19.2
|
||||
version: 24.19.2
|
||||
'@types/nodemailer':
|
||||
specifier: 8.0.2
|
||||
version: 8.0.2
|
||||
'@types/pg':
|
||||
specifier: ^8.23.1
|
||||
version: 8.23.1
|
||||
@@ -1481,6 +1487,9 @@ packages:
|
||||
'@types/node@26.6.5':
|
||||
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
|
||||
|
||||
'@types/nodemailer@8.0.2':
|
||||
resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==}
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||
|
||||
@@ -3067,6 +3076,10 @@ packages:
|
||||
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
nodemailer@10.0.10:
|
||||
resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
non-error@0.1.0:
|
||||
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -4910,6 +4923,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 8.9.0
|
||||
|
||||
'@types/nodemailer@8.0.2':
|
||||
dependencies:
|
||||
'@types/node': 24.19.2
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
dependencies:
|
||||
'@types/node': 26.6.5
|
||||
@@ -6625,6 +6642,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.58: {}
|
||||
|
||||
nodemailer@10.0.10: {}
|
||||
|
||||
non-error@0.1.0: {}
|
||||
|
||||
oauth4webapi@3.8.8: {}
|
||||
|
||||
@@ -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';
|
||||
@@ -114,6 +114,8 @@ enum VorgangsArt {
|
||||
ZIELKONTO_ANLEGEN
|
||||
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
|
||||
ZIELKONTO_ENTFERNEN
|
||||
/// Testnachricht ueber einen Benachrichtigungskanal (#028).
|
||||
BENACHRICHTIGUNG_TEST
|
||||
}
|
||||
|
||||
enum VorgangsStatus {
|
||||
@@ -125,6 +127,17 @@ enum VorgangsStatus {
|
||||
ABGEBROCHEN
|
||||
}
|
||||
|
||||
/// Versandweg einer Benachrichtigung (#028).
|
||||
enum MeldungsKanal {
|
||||
EMAIL
|
||||
WEBHOOK
|
||||
}
|
||||
|
||||
enum ZustellErgebnis {
|
||||
GESENDET
|
||||
FEHLGESCHLAGEN
|
||||
}
|
||||
|
||||
enum ProtokollErgebnis {
|
||||
ERFOLG
|
||||
FEHLER
|
||||
@@ -274,50 +287,58 @@ model HostSchluessel {
|
||||
// ---------------------------------------------------------------- Schluessel
|
||||
|
||||
model Schluessel {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
/// Zweck, z. B. „Backup srv-backup01 → srv-pve01“.
|
||||
name String
|
||||
name String
|
||||
/// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01.
|
||||
slug String @unique
|
||||
typ SchluesselTyp
|
||||
kategorie SchluesselKategorie
|
||||
status SchluesselStatus @default(AKTIV)
|
||||
slug String @unique
|
||||
typ SchluesselTyp
|
||||
kategorie SchluesselKategorie
|
||||
status SchluesselStatus @default(AKTIV)
|
||||
/// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar".
|
||||
publicKey String
|
||||
fingerprint String @unique
|
||||
kommentar String
|
||||
publicKey String
|
||||
fingerprint String @unique
|
||||
kommentar String
|
||||
/// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts.
|
||||
privatVerschluesselt Bytes?
|
||||
privatVerschluesselt Bytes?
|
||||
/// Data-Key, verschluesselt mit dem Master-Key.
|
||||
dekVerschluesselt Bytes?
|
||||
masterKeyVersion Int?
|
||||
privatGeloeschtAm DateTime?
|
||||
passphraseGeschuetzt Boolean @default(false)
|
||||
dekVerschluesselt Bytes?
|
||||
masterKeyVersion Int?
|
||||
privatGeloeschtAm DateTime?
|
||||
passphraseGeschuetzt Boolean @default(false)
|
||||
/// Genau ein Deployment-Key ist Instanz-Standard (partieller Index).
|
||||
instanzStandard Boolean @default(false)
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
vorlageId String?
|
||||
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
|
||||
tags String[]
|
||||
ablaufAm DateTime?
|
||||
zuletztVerwendetAm DateTime?
|
||||
/// Rotationskette: der Schluessel, den dieser abgeloest hat.
|
||||
vorgaengerId String? @unique
|
||||
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
|
||||
nachfolger Schluessel? @relation("Rotation")
|
||||
erstelltVonId String?
|
||||
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
|
||||
instanzStandard Boolean @default(false)
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
vorlageId String?
|
||||
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
|
||||
tags String[]
|
||||
ablaufAm DateTime?
|
||||
zuletztVerwendetAm DateTime?
|
||||
/// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
|
||||
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
|
||||
widerrufEingeleitetAm DateTime?
|
||||
/// Rotationskette: der Schluessel, den dieser abgeloest hat. Nach einer abgebrochenen Rotation
|
||||
/// darf ein weiterer Nachfolger folgen; je Vorgaenger hoechstens ein nicht widerrufener
|
||||
/// (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).
|
||||
selbstHinterlegt Boolean @default(false)
|
||||
angelegtAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
selbstHinterlegt Boolean @default(false)
|
||||
angelegtAm DateTime @default(now())
|
||||
geaendertAm DateTime @updatedAt
|
||||
|
||||
publicVerwendungen PublicVerwendung[]
|
||||
privateVerwendungen PrivateVerwendung[]
|
||||
hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel")
|
||||
gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel")
|
||||
vorgaenge Vorgang[]
|
||||
|
||||
@@index([vorgaengerId])
|
||||
}
|
||||
|
||||
/// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe.
|
||||
@@ -333,6 +354,9 @@ model PublicVerwendung {
|
||||
zielbenutzer String
|
||||
/// authorized_keys-Optionen, z. B. restrict,from="10.0.0.12".
|
||||
optionen String?
|
||||
/// Beim Ausrollen dieselbe Zeile ausserhalb des Blocks entfernen – nur fuer uebernommene
|
||||
/// Keys (#022), sonst fasst sshkv fremde Zeilen nie an.
|
||||
ausserhalbEntfernen Boolean @default(false)
|
||||
zustand VerwendungsZustand @default(GEPLANT)
|
||||
zuletztAngewendetAm DateTime?
|
||||
angelegtVonId String?
|
||||
@@ -464,7 +488,9 @@ model Vorgang {
|
||||
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
|
||||
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
|
||||
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())
|
||||
akteurId String?
|
||||
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
|
||||
@@ -490,6 +516,30 @@ model Einstellung {
|
||||
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).
|
||||
model Ratenbegrenzung {
|
||||
schluessel String @id
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -39,6 +39,9 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
|
||||
{ name: "meine-schluessel", pfad: "/meine-schluessel" },
|
||||
{ name: "hosts", pfad: "/hosts" },
|
||||
{ name: "host-detail", pfad: "/hosts", folgen: 'main table a[href^="/hosts/"]' },
|
||||
// Matrix Schluessel × Host (#025): die Seite darf auf 390 px nicht seitwaerts scrollen –
|
||||
// nur der Matrix-Container. „belegt=1“, damit das Bild Zellen zeigt statt leerer Zeilen.
|
||||
{ name: "zuweisungen", pfad: "/zuweisungen?belegt=1" },
|
||||
{ name: "vorlagen", pfad: "/vorlagen" },
|
||||
// Die eingebaute „Deployment-Zugang“ zeigt Platzhalter, Laufzeit und Benutzer-Anlegen (#019).
|
||||
{
|
||||
@@ -46,6 +49,10 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
|
||||
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 = [
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { CheckCheck, KeyRound, ShieldCheck } 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.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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,207 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
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 { einstellungenSeite, type Zeitplan, type ZustellungZeile } from "@/server/queries/einstellungen";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export default async function Einstellungen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
function Info({ titel, children }: { titel: string; children: ReactNode }) {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Einstellungen"
|
||||
untertitel="Deployment-Key, eigene Vorlagen, Benachrichtigungen"
|
||||
phase={3}
|
||||
issue="#019"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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="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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
|
||||
import { BootstrapAktionen } from "./BootstrapAktionen";
|
||||
import { sshdHinweise } from "@/lib/sshd-hinweise";
|
||||
import { AbgleichKarten } from "@/components/abgleich/AbgleichKarten";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { istZustand } from "@/server/queries/abgleich";
|
||||
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
||||
import { verwendungenAufHost } from "@/server/queries/ziele";
|
||||
import { HostKeyAktionen } from "./HostKeyAktionen";
|
||||
@@ -100,6 +102,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
||||
const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert;
|
||||
const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert;
|
||||
const verwendungen = await verwendungenAufHost(host.id);
|
||||
const ist = await istZustand(host.id);
|
||||
const jetzt = new Date();
|
||||
|
||||
return (
|
||||
@@ -295,6 +298,16 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri
|
||||
)}
|
||||
</Karte>
|
||||
|
||||
{host.bootstrapAm && (
|
||||
<AbgleichKarten
|
||||
hostId={host.id}
|
||||
zustand={ist}
|
||||
darfAbgleichen={verwalten}
|
||||
darfUebernehmen={darf(benutzer.rolle, "schluesselVerwalten") && !archiviert}
|
||||
darfVerwalten={darfAusrollen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{verwalten && (
|
||||
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
@@ -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 Link from "next/link";
|
||||
import {
|
||||
abweichungen,
|
||||
BEISPIEL_JETZT,
|
||||
baldAblaufend,
|
||||
kennzahlen,
|
||||
letzteAktionen,
|
||||
letzterAbgleich,
|
||||
type AktionsArt,
|
||||
} from "@/beispiel/uebersicht";
|
||||
import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
@@ -18,7 +10,10 @@ import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||
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 { uebersicht, type AktionsArt } from "@/server/queries/uebersicht";
|
||||
|
||||
export const metadata: Metadata = { title: "Übersicht" };
|
||||
|
||||
@@ -28,36 +23,45 @@ const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
|
||||
erstellt: { text: "Erstellt", farbe: "text-text" },
|
||||
abgleich: { text: "Abgleich", farbe: "text-info" },
|
||||
rotiert: { text: "Rotiert", farbe: "text-accent" },
|
||||
anmeldung: { text: "Anmeldung", farbe: "text-muted" },
|
||||
verweigert: { text: "Verweigert", farbe: "text-danger" },
|
||||
sonstiges: { text: "Aktion", farbe: "text-text" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
|
||||
* folgt in #026; Aufbau und Komponenten bleiben.
|
||||
*/
|
||||
/** Übersicht (#026): Aufbau aus dem Prototyp (#002), Zahlen aus der Datenbank. */
|
||||
export default async function Uebersicht() {
|
||||
// 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 (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Übersicht"
|
||||
badges={<Badge variante="messing">Prototyp · Beispieldaten</Badge>}
|
||||
untertitel={
|
||||
<>
|
||||
Letzter Abgleich {relativ(letzterAbgleich, BEISPIEL_JETZT)} ·{" "}
|
||||
<time dateTime={letzterAbgleich} className="font-mono text-[13px]">
|
||||
{datumZeit(letzterAbgleich)}
|
||||
</time>
|
||||
</>
|
||||
letzterAbgleich ? (
|
||||
<>
|
||||
Letzter Abgleich {relativ(letzterAbgleich, jetzt)} ·{" "}
|
||||
<time dateTime={letzterAbgleich.toISOString()} className="font-mono text-[13px]">
|
||||
{datumZeit(letzterAbgleich)}
|
||||
</time>
|
||||
</>
|
||||
) : (
|
||||
"Noch kein Abgleich gelaufen."
|
||||
)
|
||||
}
|
||||
aktionen={
|
||||
<>
|
||||
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
|
||||
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||
Host-zu-Host-Assistent
|
||||
</Button>
|
||||
{verwalten && <AlleAbgleichenKnopf />}
|
||||
{darf(benutzer.rolle, "privateKeyAusrollen") && (
|
||||
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||
Host-zu-Host-Assistent
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
href="/schluessel"
|
||||
href="/schluessel/neu"
|
||||
variante="primaer"
|
||||
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||
>
|
||||
@@ -97,46 +101,60 @@ export default async function Uebersicht() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Tabelle
|
||||
beschriftung="Hosts mit Abweichung"
|
||||
spalten={[
|
||||
{ titel: "Host" },
|
||||
{ titel: "Benutzer" },
|
||||
{ titel: "Befund" },
|
||||
{ titel: "Aktion", rechts: true, versteckt: true },
|
||||
]}
|
||||
>
|
||||
{abweichungen.map((a) => (
|
||||
<TabellenZeile key={`${a.host}-${a.benutzer}`}>
|
||||
<TabellenZelle mono className="whitespace-nowrap">
|
||||
{a.host}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<Statuspunkt status={a.status} text={a.befund} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle rechts>
|
||||
<Link
|
||||
href="/hosts"
|
||||
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
|
||||
titel="Hosts mit Abweichung"
|
||||
untertitel={
|
||||
weitereAbweichungen
|
||||
? `Ergebnis des letzten Abgleichs · ${weitereAbweichungen} weitere`
|
||||
: "Ergebnis des letzten Abgleichs"
|
||||
}
|
||||
>
|
||||
{mitAbweichung.length === 0 ? (
|
||||
<p className="text-ok text-[14px]">● Alle Hosts synchron – keine offenen Abweichungen.</p>
|
||||
) : (
|
||||
<Tabelle
|
||||
beschriftung="Hosts mit Abweichung"
|
||||
spalten={[
|
||||
{ titel: "Host" },
|
||||
{ titel: "Benutzer" },
|
||||
{ titel: "Befund" },
|
||||
{ titel: "Aktion", rechts: true, versteckt: true },
|
||||
]}
|
||||
>
|
||||
{mitAbweichung.map((a) => (
|
||||
<TabellenZeile key={`${a.hostId}-${a.benutzer}-${a.status}`}>
|
||||
<TabellenZelle mono className="whitespace-nowrap">
|
||||
{a.host}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<Statuspunkt status={a.status} text={a.befund} />
|
||||
</TabellenZelle>
|
||||
<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 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">
|
||||
{baldAblaufend.map((s) => {
|
||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
||||
const tage = tageBis(s.ablauf, jetzt);
|
||||
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
|
||||
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"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
@@ -145,8 +163,16 @@ export default async function Uebersicht() {
|
||||
{s.person} · <span className="font-mono">{s.typ}</span>
|
||||
</span>
|
||||
</span>
|
||||
<Badge variante="messing" className="shrink-0">
|
||||
<time dateTime={s.ablauf}>{tage === 1 ? "morgen" : `in ${tage} Tagen`}</time>
|
||||
<Badge variante={tage < 0 ? "gefahr" : "messing"} className="shrink-0">
|
||||
<time dateTime={s.ablauf.toISOString()}>
|
||||
{tage < 0
|
||||
? `abgelaufen${s.ausgerollt ? " · noch ausgerollt" : ""}`
|
||||
: tage === 0
|
||||
? "heute"
|
||||
: tage === 1
|
||||
? "morgen"
|
||||
: `in ${tage} Tagen`}
|
||||
</time>
|
||||
</Badge>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -167,13 +193,17 @@ export default async function Uebersicht() {
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{letzteAktionen.length === 0 && <Leerzustand titel="Noch keine Aktionen" />}
|
||||
<ol className="flex flex-col">
|
||||
{letzteAktionen.map((a) => (
|
||||
<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"
|
||||
>
|
||||
<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)}
|
||||
</time>
|
||||
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>
|
||||
|
||||
@@ -1,18 +1,202 @@
|
||||
import { Download, ScrollText, Search } from "lucide-react";
|
||||
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 { protokollAkteure, protokollSeite, protokollZaehlen } from "@/server/queries/protokoll";
|
||||
import { ketteZwischengespeichert, syslogKonfig } from "@/server/services/protokoll";
|
||||
|
||||
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).
|
||||
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 (
|
||||
<Platzhalterseite
|
||||
titel="Audit-Log"
|
||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
|
||||
phase={5}
|
||||
issue="#027"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Audit-Log"
|
||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen – nur lesbar, nicht änderbar"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Download, Lock, LockOpen, Pencil } from "lucide-react";
|
||||
import { Download, Lock, LockOpen, Pencil, RefreshCw } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -15,10 +15,16 @@ import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
|
||||
import { datum, datumZeit } from "@/lib/format";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||
import { rotationsKette, schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||
import { zielAuswahl } from "@/server/queries/ziele";
|
||||
import { widerrufUebersicht } from "@/server/queries/widerruf";
|
||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
|
||||
import { ablaufZustand } from "@/lib/schluessel";
|
||||
import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen";
|
||||
import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation";
|
||||
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||
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. */
|
||||
export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
export default async function SchluesselSeite({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ zielkonten?: string | string[] }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
|
||||
const s = await schluesselDetail(id);
|
||||
if (!s) notFound();
|
||||
const zielkontenParameter = (await searchParams).zielkonten;
|
||||
|
||||
const aktiv = s.status === "AKTIV";
|
||||
// Im Widerruf (#024) bleibt der Status AKTIV, bis alles entfernt ist – zuweisen geht aber nicht mehr.
|
||||
const imWiderruf = Boolean(s.widerrufEingeleitetAm) && (s.status === "AKTIV" || s.status === "ABGELAUFEN");
|
||||
const aktiv = s.status === "AKTIV" && !imWiderruf;
|
||||
const rechte: Rechte = {
|
||||
public: darf(benutzer.rolle, "publicKeyAusrollen"),
|
||||
privat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
testen: darf(benutzer.rolle, "hostsVerwalten"),
|
||||
};
|
||||
const jetzt = new Date();
|
||||
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
|
||||
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
|
||||
// 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 personenKey = s.kategorie === "PERSON";
|
||||
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 darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
|
||||
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
|
||||
const jetzt = new Date();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
@@ -228,6 +293,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||
<KategorieBadge kategorie={s.kategorie} />
|
||||
<StatusBadge status={s.status} />
|
||||
{imWiderruf && <Badge variante="gefahr">Widerruf unvollständig</Badge>}
|
||||
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
|
||||
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
||||
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
|
||||
@@ -240,14 +306,121 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
</>
|
||||
}
|
||||
aktionen={
|
||||
verwalten && (
|
||||
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
(verwalten || rotierbar || (widerrufbar && widerruf)) && (
|
||||
<>
|
||||
{verwalten && (
|
||||
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
{rotierbar && (
|
||||
<Button href={`/schluessel/${s.id}/rotieren`} icon={<RefreshCw className="size-4" />}>
|
||||
{rotationLaeuft ? "Rotation fortsetzen" : "Rotieren"}
|
||||
</Button>
|
||||
)}
|
||||
{widerrufbar && widerruf && (
|
||||
<WiderrufKnopf uebersicht={widerruf} ruecksprung={`/schluessel/${s.id}`} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{rotationDesVorgaengers &&
|
||||
(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">
|
||||
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
||||
<InfoFeld titel="Fingerprint">
|
||||
@@ -321,8 +494,13 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
</div>
|
||||
|
||||
{rechte.public &&
|
||||
!imWiderruf &&
|
||||
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
|
||||
<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"
|
||||
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>
|
||||
)}
|
||||
|
||||
{(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
|
||||
titel="Public Key"
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { KeyRound, Lock, Plus, Search } from "lucide-react";
|
||||
import { Ban, KeyRound, Lock, Plus, Search } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { AlleAuswaehlen } from "@/components/widerruf/AlleAuswaehlen";
|
||||
import { Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
@@ -50,6 +52,31 @@ function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) {
|
||||
);
|
||||
}
|
||||
|
||||
const MASSEN_FORMULAR = "massenwiderruf";
|
||||
|
||||
/** Nur Schluessel, die sich widerrufen lassen, sind waehlbar (#024). */
|
||||
function Auswahl({ s }: { s: SchluesselListenEintrag }) {
|
||||
const gesperrt = s.kategorie === "DEPLOYMENT" || s.status === "WIDERRUFEN" || s.status === "ROTIERT";
|
||||
return (
|
||||
<label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="id"
|
||||
value={s.id}
|
||||
form={MASSEN_FORMULAR}
|
||||
disabled={gesperrt}
|
||||
aria-label={`${s.name} für den Massenwiderruf auswählen`}
|
||||
className="accent-accent size-[18px] cursor-pointer disabled:cursor-not-allowed disabled:opacity-30"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function WiderrufBadge({ s }: { s: SchluesselListenEintrag }) {
|
||||
if (!s.widerrufEingeleitetAm || (s.status !== "AKTIV" && s.status !== "ABGELAUFEN")) return null;
|
||||
return <Badge variante="gefahr">Widerruf unvollständig</Badge>;
|
||||
}
|
||||
|
||||
/** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */
|
||||
export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
@@ -60,6 +87,8 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
const status = einzeln(parameter["status"]);
|
||||
const liste = await schluesselListe({ q, kategorie, status });
|
||||
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||
// Massenwiderruf (#024): Auswahl nur fuer Admins; die frische Bestaetigung prueft die Aktion.
|
||||
const massen = darf(benutzer.rolle, "massenwiderruf");
|
||||
const gefiltert = q !== "" || kategorie !== "" || status !== "";
|
||||
const jetzt = new Date();
|
||||
|
||||
@@ -142,13 +171,27 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
)
|
||||
) : (
|
||||
<Karte>
|
||||
{massen && (
|
||||
<form
|
||||
id={MASSEN_FORMULAR}
|
||||
method="get"
|
||||
action="/schluessel/widerruf"
|
||||
className="border-border mb-3 flex flex-wrap items-center justify-between gap-2 border-b pb-3"
|
||||
>
|
||||
<AlleAuswaehlen formular={MASSEN_FORMULAR} />
|
||||
<Button type="submit" variante="gefahr" klein icon={<Ban className="size-4" />}>
|
||||
Ausgewählte widerrufen
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */}
|
||||
<ul className="flex flex-col md:hidden">
|
||||
{liste.map((s) => (
|
||||
<li key={s.id} className="border-border border-b last:border-b-0">
|
||||
<li key={s.id} className="border-border flex items-start gap-1 border-b last:border-b-0">
|
||||
{massen && <Auswahl s={s} />}
|
||||
<Link
|
||||
href={`/schluessel/${s.id}`}
|
||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-2 px-2 py-3 transition-colors"
|
||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 min-w-0 flex-1 flex-col gap-2 px-2 py-3 transition-colors"
|
||||
>
|
||||
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
|
||||
<span className="truncate">{s.name}</span>
|
||||
@@ -161,6 +204,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||
<KategorieBadge kategorie={s.kategorie} />
|
||||
<StatusBadge status={s.status} />
|
||||
<WiderrufBadge s={s} />
|
||||
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||
</span>
|
||||
<span className="text-muted text-[13px]">
|
||||
@@ -175,6 +219,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
className="hidden md:block"
|
||||
beschriftung="Schlüssel"
|
||||
spalten={[
|
||||
...(massen ? [{ titel: "Auswahl", versteckt: true, className: "w-10" }] : []),
|
||||
{ titel: "Name" },
|
||||
{ titel: "Typ" },
|
||||
{ titel: "Kategorie" },
|
||||
@@ -185,6 +230,11 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
>
|
||||
{liste.map((s) => (
|
||||
<TabellenZeile key={s.id}>
|
||||
{massen && (
|
||||
<TabellenZelle className="w-10">
|
||||
<Auswahl s={s} />
|
||||
</TabellenZelle>
|
||||
)}
|
||||
<TabellenZelle>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Link
|
||||
@@ -195,6 +245,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
</Link>
|
||||
<PrivatHinweis s={s} />
|
||||
<StatusBadge status={s.status} />
|
||||
<WiderrufBadge s={s} />
|
||||
</span>
|
||||
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
|
||||
</TabellenZelle>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,164 @@
|
||||
import { Download, Network, Search } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import { Zuweisungsmatrix } from "@/components/zuweisungen/Zuweisungsmatrix";
|
||||
import { Button, buttonKlassen } from "@/components/ui/Button";
|
||||
import { Ankreuzfeld, Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||
import { Kennzahl } from "@/components/ui/Kennzahl";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { KATEGORIE_TEXT } from "@/lib/schluessel";
|
||||
import { matrixFilterAus, matrixFilterQuery } from "@/lib/zuweisungsmatrix";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { zielAuswahl } from "@/server/queries/ziele";
|
||||
import { zuweisungsMatrix } from "@/server/queries/zuweisungen";
|
||||
|
||||
export const metadata: Metadata = { title: "Zuweisungen" };
|
||||
|
||||
export default async function Zuweisungen() {
|
||||
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
/**
|
||||
* Zuweisungsmatrix Schluessel × Host (#025). Filter per GET, damit Links und der CSV-Export
|
||||
* denselben Ausschnitt zeigen. Die Matrix plant nur – ausgerollt wird ueber den Dry-Run.
|
||||
*/
|
||||
export default async function Zuweisungen({ searchParams }: { searchParams: Suchparameter }) {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
const { benutzer } = await sitzungErforderlich("lesen", "/zuweisungen");
|
||||
const filter = matrixFilterAus(await searchParams);
|
||||
const darfZuweisen = darf(benutzer.rolle, "publicKeyAusrollen");
|
||||
const [{ matrix, gruppen, personen }, auswahl] = await Promise.all([
|
||||
zuweisungsMatrix(filter),
|
||||
darfZuweisen ? zielAuswahl() : Promise.resolve(undefined),
|
||||
]);
|
||||
const gefiltert = Boolean(
|
||||
filter.gruppeId || filter.kategorie || filter.personId || filter.q || filter.nurBelegt,
|
||||
);
|
||||
const { zaehler } = matrix;
|
||||
const leer = matrix.zeilen.length === 0 || matrix.spalten.length === 0;
|
||||
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Zuweisungen"
|
||||
untertitel="Welcher Schlüssel auf welchem Host-Konto gilt"
|
||||
phase={2}
|
||||
issue="#016"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Zuweisungen"
|
||||
untertitel="Welcher Schlüssel auf welchem Host verwaltet ist"
|
||||
aktionen={
|
||||
<a
|
||||
href={`/api/zuweisungen.csv${matrixFilterQuery(filter)}`}
|
||||
download
|
||||
className={buttonKlassen("sekundaer")}
|
||||
>
|
||||
<Download className="size-4" aria-hidden />
|
||||
CSV exportieren
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-5 xl:grid-cols-4">
|
||||
<Kennzahl titel="Schlüssel" wert={zaehler.schluessel} hinweis="aktiv, in der Ansicht" />
|
||||
<Kennzahl titel="Hosts" wert={zaehler.hosts} hinweis="nicht archiviert" />
|
||||
<Kennzahl titel="Zuweisungen" wert={zaehler.zuweisungen} hinweis="Gruppen je Host gezählt" />
|
||||
<Kennzahl
|
||||
titel="Nicht synchron"
|
||||
wert={zaehler.nichtSynchron}
|
||||
variante={zaehler.nichtSynchron > 0 ? "messing" : "neutral"}
|
||||
hinweis="geplant, Drift, entfernen, Fehler"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="get"
|
||||
role="search"
|
||||
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_10rem_11rem]"
|
||||
>
|
||||
<Feld
|
||||
beschriftung="Suche"
|
||||
name="q"
|
||||
type="search"
|
||||
defaultValue={filter.q}
|
||||
placeholder="Schlüssel- oder Hostname"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="sm:col-span-2 lg:col-span-1"
|
||||
/>
|
||||
<Auswahlliste beschriftung="Hostgruppe" name="gruppe" defaultValue={filter.gruppeId}>
|
||||
<option value="">Alle Hosts</option>
|
||||
{gruppen.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={filter.kategorie}>
|
||||
<option value="">Alle</option>
|
||||
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
|
||||
<option key={wert} value={wert}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Auswahlliste beschriftung="Person" name="person" defaultValue={filter.personId}>
|
||||
<option value="">Alle Personen</option>
|
||||
{personen.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<div className="flex flex-col gap-3 sm:col-span-2 sm:flex-row sm:items-center sm:justify-between lg:col-span-4">
|
||||
<Ankreuzfeld
|
||||
name="belegt"
|
||||
value="1"
|
||||
defaultChecked={filter.nurBelegt}
|
||||
beschriftung="Nur Zeilen und Spalten mit Zuweisungen"
|
||||
className="-mx-2"
|
||||
/>
|
||||
<div className="flex gap-2.5">
|
||||
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||
Filtern
|
||||
</Button>
|
||||
{gefiltert && (
|
||||
<Button href="/zuweisungen" className="flex-1 sm:flex-none">
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{leer ? (
|
||||
gefiltert ? (
|
||||
<Leerzustand
|
||||
icon={<Search className="size-5" />}
|
||||
titel="Keine Treffer"
|
||||
text="Kein Schlüssel und kein Host passen zu Suche und Filter."
|
||||
aktion={<Button href="/zuweisungen">Filter zurücksetzen</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Leerzustand
|
||||
icon={<Network className="size-5" />}
|
||||
titel="Noch nichts zuzuordnen"
|
||||
text="Die Matrix braucht mindestens einen aktiven Schlüssel und einen Host."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<section className="flex min-w-0 flex-col gap-3" aria-label="Matrix">
|
||||
<p className="text-muted text-[13px]">
|
||||
<span className="text-text font-medium">P</span> Public Key direkt ·{" "}
|
||||
<span className="text-text font-medium">P·G</span> über Hostgruppe ·{" "}
|
||||
<span className="text-text font-medium">K</span> Private Key · <span className="text-ok">●</span>{" "}
|
||||
synchron · <span className="text-accent">○</span> geplant oder wird entfernt ·{" "}
|
||||
<span className="text-danger">●</span> Drift oder Fehler
|
||||
</p>
|
||||
<Zuweisungsmatrix
|
||||
zeilen={matrix.zeilen}
|
||||
spalten={matrix.spalten}
|
||||
zellen={Object.fromEntries(matrix.zellen)}
|
||||
darfZuweisen={darfZuweisen}
|
||||
darfPrivatEntfernen={darf(benutzer.rolle, "privateKeyAusrollen")}
|
||||
{...(auswahl ? { auswahl } : {})}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,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",
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ArrowRight, Check, ExternalLink, Play } from "lucide-react";
|
||||
import { ArrowLeft, ArrowRight, ExternalLink, Play } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
|
||||
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
|
||||
import { 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 {
|
||||
@@ -91,19 +92,6 @@ function kurz(text: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** Wartet auf das Ende eines Vorgangs (fuer die Kette im Klick, nicht als Hook). */
|
||||
async function warten(
|
||||
vorgangId: string,
|
||||
): Promise<{ status: string; fehler: string | null; ergebnis: unknown }> {
|
||||
for (;;) {
|
||||
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
|
||||
const stand = (await antwort.json()) as { status: string; fehler: string | null; ergebnis: unknown };
|
||||
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
|
||||
await new Promise((weiter) => setTimeout(weiter, 800));
|
||||
}
|
||||
}
|
||||
|
||||
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
|
||||
const [schritt, setSchritt] = useState(0);
|
||||
const [w, setW] = useState<Werte>(LEER);
|
||||
@@ -249,27 +237,7 @@ export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestae
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ol className="flex flex-wrap gap-2" aria-label="Schritte">
|
||||
{SCHRITTE.map((titel, index) => (
|
||||
<li
|
||||
key={titel}
|
||||
aria-current={index === schritt ? "step" : undefined}
|
||||
className={clsx(
|
||||
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
|
||||
index === schritt
|
||||
? "border-accent text-accent bg-raised font-medium"
|
||||
: index < schritt
|
||||
? "border-border text-text"
|
||||
: "border-border text-muted",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{index < schritt ? <Check className="size-4" aria-hidden /> : index + 1}
|
||||
</span>
|
||||
{titel}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} />
|
||||
|
||||
{schritt === 0 && (
|
||||
<Karte
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Play, ScanSearch } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DiffBox } from "@/components/ui/DiffBox";
|
||||
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
|
||||
@@ -34,7 +34,16 @@ type PlanZiel = {
|
||||
fehler?: string;
|
||||
};
|
||||
|
||||
export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; offen: number }) {
|
||||
export function AusrollenPanel({
|
||||
auswahl,
|
||||
offen,
|
||||
nachAusrollen,
|
||||
}: {
|
||||
auswahl: AusrollAuswahl;
|
||||
offen: number;
|
||||
/** Nach erfolgreichem Ausrollen – etwa um einen Widerruf abzuschliessen (#024). */
|
||||
nachAusrollen?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [trockenlaufId, setTrockenlaufId] = useState<string>();
|
||||
const [ausrollenId, setAusrollenId] = useState<string>();
|
||||
@@ -43,12 +52,22 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
||||
const trockenlauf = useVorgang(trockenlaufId);
|
||||
const ausrollen = useVorgang(ausrollenId);
|
||||
|
||||
// Ueber eine Ref, damit der Rueckruf genau einmal je Lauf feuert und nicht bei jedem Render.
|
||||
const rueckruf = useRef(nachAusrollen);
|
||||
useEffect(() => {
|
||||
if (ausrollen.stand && !laeuftNoch(ausrollen.stand)) router.refresh();
|
||||
rueckruf.current = nachAusrollen;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!ausrollen.stand || laeuftNoch(ausrollen.stand)) return;
|
||||
router.refresh();
|
||||
if (ausrollen.stand.status === "ERLEDIGT") rueckruf.current?.();
|
||||
}, [ausrollen.stand, router]);
|
||||
|
||||
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
|
||||
const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0;
|
||||
// Steht schon alles so auf dem Host (etwa ein Key, den jemand von Hand entfernt hat), gibt es
|
||||
// nichts zu schreiben – der Zustand der Zuweisungen muss trotzdem nachgezogen werden (#024).
|
||||
const nurZustand = aenderungen === 0 && offen > 0 && (plan?.some((z) => !z.fehler) ?? false);
|
||||
const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand);
|
||||
const fertig = ausrollen.stand?.status === "ERLEDIGT";
|
||||
|
||||
@@ -103,7 +122,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
||||
</div>
|
||||
))}
|
||||
|
||||
{plan && aenderungen > 0 && !fertig && (
|
||||
{plan && (aenderungen > 0 || nurZustand) && !fertig && (
|
||||
<div className="border-border flex flex-col gap-3 border-t pt-4">
|
||||
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
|
||||
<input type="checkbox" checked disabled className="accent-accent size-5" />
|
||||
@@ -124,7 +143,11 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuftNoch(ausrollen.stand) ? "Wird ausgerollt …" : `Änderung anwenden (${aenderungen})`}
|
||||
{laeuftNoch(ausrollen.stand)
|
||||
? "Wird ausgerollt …"
|
||||
: nurZustand
|
||||
? "Zustand übernehmen"
|
||||
: `Änderung anwenden (${aenderungen})`}
|
||||
</Button>
|
||||
<Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
|
||||
Verwerfen
|
||||
@@ -132,7 +155,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{plan && aenderungen === 0 && (
|
||||
{plan && aenderungen === 0 && !fertig && (
|
||||
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
|
||||
)}
|
||||
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && (
|
||||
|
||||
@@ -14,10 +14,13 @@ export function PublicZuweisenFormular({
|
||||
schluesselId,
|
||||
auswahl,
|
||||
beiErfolg,
|
||||
vorauswahl,
|
||||
}: {
|
||||
schluesselId: string;
|
||||
auswahl: ZielAuswahl;
|
||||
beiErfolg?: () => void;
|
||||
/** Vorausgewaehltes Ziel als „host:<id>“ oder „gruppe:<id>“, z. B. aus der Matrix (#025). */
|
||||
vorauswahl?: string;
|
||||
}) {
|
||||
const { zustand, absenden, laeuft } = useFormularAktion(async (vorher, formular) => {
|
||||
const ergebnis = await publicZuweisenAktion(vorher, formular);
|
||||
@@ -28,7 +31,7 @@ export function PublicZuweisenFormular({
|
||||
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||
<input type="hidden" name="schluesselId" value={schluesselId} />
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue="" required>
|
||||
<Auswahlliste beschriftung="Ziel" name="ziel" defaultValue={vorauswahl ?? ""} required>
|
||||
<option value="" disabled>
|
||||
Host oder Hostgruppe wählen …
|
||||
</option>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */
|
||||
const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
|
||||
export const ZUSTAND_ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }> = {
|
||||
GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" },
|
||||
AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" },
|
||||
ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" },
|
||||
@@ -12,7 +12,7 @@ const ANZEIGE: Record<Zustand, { text: string; klasse: string; punkt: string }>
|
||||
};
|
||||
|
||||
export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) {
|
||||
const anzeige = ANZEIGE[zustand];
|
||||
const anzeige = ZUSTAND_ANZEIGE[zustand];
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { Repeat } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { umstellenAktion } from "@/server/actions/deploymentkey";
|
||||
|
||||
/**
|
||||
* Schritt 4 der Rotation (#031). Hosts, auf denen die Pruefung scheiterte, lassen sich bewusst
|
||||
* uebergehen – sshkv kommt danach nicht mehr auf sie und sie brauchen einen neuen Bootstrap.
|
||||
*/
|
||||
export function Umstellen({
|
||||
fehlgeschlagen,
|
||||
}: {
|
||||
fehlgeschlagen: readonly { hostId: string; hostName: string }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [uebergehen, setUebergehen] = useState<string[]>([]);
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [laeuft, starten] = useTransition();
|
||||
const bereit = fehlgeschlagen.every((h) => uebergehen.includes(h.hostId));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{fehlgeschlagen.length > 0 && (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-danger mb-2 text-[13.5px]">
|
||||
Auf diesen Hosts hat der neue Key nicht funktioniert. Übergangene Hosts erreicht sshkv nach dem
|
||||
Umstellen nicht mehr – sie brauchen einen neuen Bootstrap.
|
||||
</legend>
|
||||
{fehlgeschlagen.map((h) => (
|
||||
<label key={h.hostId} className="flex min-h-11 items-center gap-3 text-[14px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent size-4"
|
||||
checked={uebergehen.includes(h.hostId)}
|
||||
onChange={(e) =>
|
||||
setUebergehen((alt) =>
|
||||
e.target.checked ? [...alt, h.hostId] : alt.filter((id) => id !== h.hostId),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-mono">{h.hostName}</span> übergehen
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<Repeat className="size-4" />}
|
||||
disabled={!bereit || laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
const antwort = await umstellenAktion(uebergehen);
|
||||
if (antwort.fehler) setFehler(antwort.fehler);
|
||||
else router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
Auf neuen Key umstellen
|
||||
</Button>
|
||||
{fehler && (
|
||||
<p role="alert" className="text-danger text-[13.5px]">
|
||||
{fehler}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { Mail, Send, Webhook } from "lucide-react";
|
||||
import { startTransition, useActionState, useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Ankreuzfeld,
|
||||
Badge,
|
||||
Button,
|
||||
Feld,
|
||||
Feldgruppe,
|
||||
Formularfehler,
|
||||
Karte,
|
||||
Textbereich,
|
||||
} from "@/components/ui";
|
||||
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||
import type { BenachrichtigungAnsicht } from "@/lib/benachrichtigung/einstellungen";
|
||||
import { ANLAESSE, ANLASS_TEXT } from "@/lib/benachrichtigung/regeln";
|
||||
import { benachrichtigungSpeichernAktion, testSendenAktion } from "@/server/actions/einstellungen";
|
||||
import type { FormularZustand } from "@/server/actions/formular";
|
||||
|
||||
type Zustand = FormularZustand & { gespeichert?: boolean };
|
||||
|
||||
/** Fester Anfangszustand: ein neues `{}` je Render liesse den Vergleich unten nie zur Ruhe kommen. */
|
||||
const ANFANG: Zustand = {};
|
||||
|
||||
/**
|
||||
* Benachrichtigungskanaele und Anlaesse (#028). Geheimnisse kommen nie zurueck in den Browser:
|
||||
* die Felder starten leer, „gesetzt“ zeigt, dass ein Wert hinterlegt ist, und leer speichern
|
||||
* laesst ihn unveraendert.
|
||||
*/
|
||||
export function BenachrichtigungFormular({
|
||||
ansicht,
|
||||
darfAendern,
|
||||
}: {
|
||||
ansicht: BenachrichtigungAnsicht;
|
||||
darfAendern: boolean;
|
||||
}) {
|
||||
const [zustand, ausfuehren, laeuft] = useActionState<Zustand, FormData>(
|
||||
benachrichtigungSpeichernAktion,
|
||||
ANFANG,
|
||||
);
|
||||
// Nach dem Speichern neu aufbauen: Passwortfelder leeren sich, die Vorgaben kommen frisch
|
||||
// vom Server (revalidatePath). Ueber `key` statt Formular-Reset, damit Fehlerfaelle die
|
||||
// Eingaben behalten (Muster aus useFormularAktion, #008).
|
||||
const [fassung, setFassung] = useState(0);
|
||||
const [gemerkt, setGemerkt] = useState<Zustand>(zustand);
|
||||
if (zustand !== gemerkt) {
|
||||
setGemerkt(zustand);
|
||||
if (zustand.gespeichert) setFassung((f) => f + 1);
|
||||
}
|
||||
|
||||
function absenden(ereignis: FormEvent<HTMLFormElement>) {
|
||||
ereignis.preventDefault();
|
||||
const daten = new FormData(ereignis.currentTarget);
|
||||
startTransition(() => ausfuehren(daten));
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
key={fassung}
|
||||
onSubmit={absenden}
|
||||
noValidate
|
||||
className="flex flex-col gap-6"
|
||||
aria-label="Benachrichtigungen"
|
||||
>
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
{zustand.gespeichert && !laeuft && (
|
||||
<p role="status" className="text-ok text-[14px]">
|
||||
Einstellungen gespeichert.
|
||||
</p>
|
||||
)}
|
||||
<Inhalt ansicht={ansicht} darfAendern={darfAendern} f={zustand.feldfehler ?? {}} />
|
||||
{darfAendern && (
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Benachrichtigungen speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Inhalt({
|
||||
ansicht,
|
||||
darfAendern,
|
||||
f,
|
||||
}: {
|
||||
ansicht: BenachrichtigungAnsicht;
|
||||
darfAendern: boolean;
|
||||
f: Record<string, string>;
|
||||
}) {
|
||||
const [sicherheit, setSicherheit] = useState(ansicht.email.sicherheit);
|
||||
const [httpErlaubt, setHttpErlaubt] = useState(ansicht.webhook.httpErlaubt);
|
||||
const { email, webhook } = ansicht;
|
||||
|
||||
return (
|
||||
// Leser sehen dieselben Felder, aber gesperrt und ohne Knoepfe.
|
||||
<fieldset disabled={!darfAendern} className="flex min-w-0 flex-col gap-6">
|
||||
<Karte
|
||||
titel={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Mail className="text-muted size-4" aria-hidden /> E-Mail (SMTP)
|
||||
</span>
|
||||
}
|
||||
untertitel="Vorbelegt für Microsoft 365: smtp.office365.com, Port 587, STARTTLS."
|
||||
aktionen={<KanalStatus aktiv={email.aktiv} />}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Ankreuzfeld
|
||||
name="emailAktiv"
|
||||
value="ja"
|
||||
defaultChecked={email.aktiv}
|
||||
beschriftung="Per E-Mail benachrichtigen"
|
||||
/>
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_6.5rem] gap-3">
|
||||
<Feld
|
||||
beschriftung="Server"
|
||||
name="emailServer"
|
||||
defaultValue={email.server}
|
||||
mono
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
fehler={f["emailServer"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Port"
|
||||
name="emailPort"
|
||||
defaultValue={email.port}
|
||||
mono
|
||||
inputMode="numeric"
|
||||
fehler={f["emailPort"]}
|
||||
/>
|
||||
</div>
|
||||
<Feldgruppe legende="Verschlüsselung" fehler={f["emailSicherheit"]}>
|
||||
<div className="grid gap-x-4 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
["STARTTLS", "STARTTLS", "Port 587 – Microsoft 365"],
|
||||
["TLS", "TLS (SMTPS)", "Port 465"],
|
||||
["KEINE", "Unverschlüsselt", "nur internes Relay"],
|
||||
] as const
|
||||
).map(([wert, titel, erklaerung]) => (
|
||||
<Ankreuzfeld
|
||||
key={wert}
|
||||
type="radio"
|
||||
name="emailSicherheit"
|
||||
value={wert}
|
||||
defaultChecked={email.sicherheit === wert}
|
||||
onChange={() => setSicherheit(wert)}
|
||||
beschriftung={titel}
|
||||
erklaerung={erklaerung}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{sicherheit === "KEINE" && (
|
||||
<Warnung>
|
||||
Ohne Verschlüsselung gehen Zugangsdaten und Inhalt im Klartext über das Netz. Nur für ein
|
||||
Relay im selben, vertrauenswürdigen Netz verwenden.
|
||||
</Warnung>
|
||||
)}
|
||||
</Feldgruppe>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Absender"
|
||||
name="emailAbsender"
|
||||
type="email"
|
||||
defaultValue={email.absender}
|
||||
autoComplete="off"
|
||||
placeholder="sshkv@example.de"
|
||||
fehler={f["emailAbsender"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Benutzer (optional)"
|
||||
name="emailBenutzer"
|
||||
defaultValue={email.benutzer}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
hinweis="Bei Microsoft 365 meist die Absenderadresse."
|
||||
fehler={f["emailBenutzer"]}
|
||||
/>
|
||||
</div>
|
||||
<GeheimFeld
|
||||
beschriftung="Passwort"
|
||||
name="emailPasswort"
|
||||
gesetzt={email.passwortGesetzt}
|
||||
darfAendern={darfAendern}
|
||||
fehler={f["emailPasswort"]}
|
||||
/>
|
||||
<Textbereich
|
||||
beschriftung="Empfänger"
|
||||
name="emailEmpfaenger"
|
||||
defaultValue={email.empfaenger.join("\n")}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
placeholder="admin@example.de"
|
||||
hinweis="Eine Adresse je Zeile oder durch Komma getrennt."
|
||||
fehler={f["emailEmpfaenger"]}
|
||||
/>
|
||||
{darfAendern && <TestKnopf kanal="EMAIL" />}
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<Karte
|
||||
titel={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Webhook className="text-muted size-4" aria-hidden /> Webhook
|
||||
</span>
|
||||
}
|
||||
untertitel="JSON per POST, optional signiert mit X-Sshkv-Signatur: sha256=<HMAC des Bodys>."
|
||||
aktionen={<KanalStatus aktiv={webhook.aktiv} />}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Ankreuzfeld
|
||||
name="webhookAktiv"
|
||||
value="ja"
|
||||
defaultChecked={webhook.aktiv}
|
||||
beschriftung="Per Webhook benachrichtigen"
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Adresse"
|
||||
name="webhookUrl"
|
||||
type="url"
|
||||
defaultValue={webhook.url}
|
||||
mono
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="https://alarm.example.de/hooks/sshkv"
|
||||
fehler={f["webhookUrl"]}
|
||||
/>
|
||||
<div>
|
||||
<Ankreuzfeld
|
||||
name="webhookHttpErlaubt"
|
||||
value="ja"
|
||||
defaultChecked={webhook.httpErlaubt}
|
||||
onChange={(e) => setHttpErlaubt(e.currentTarget.checked)}
|
||||
beschriftung="http:// für ein internes Ziel erlauben"
|
||||
erklaerung="Sonst nimmt sshkv nur https://-Adressen an."
|
||||
/>
|
||||
{httpErlaubt && (
|
||||
<Warnung>
|
||||
Über http:// sind Nutzlast und Signatur unterwegs lesbar und veränderbar. Nur für Ziele im
|
||||
selben, vertrauenswürdigen Netz.
|
||||
</Warnung>
|
||||
)}
|
||||
</div>
|
||||
<GeheimFeld
|
||||
beschriftung="Geheimnis für die Signatur (optional)"
|
||||
name="webhookGeheimnis"
|
||||
gesetzt={webhook.geheimnisGesetzt}
|
||||
darfAendern={darfAendern}
|
||||
fehler={f["webhookGeheimnis"]}
|
||||
/>
|
||||
{darfAendern && <TestKnopf kanal="WEBHOOK" />}
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<Karte
|
||||
titel="Anlässe"
|
||||
untertitel="Drift, Ablauf und Erreichbarkeit kommen gesammelt in der täglichen Zusammenfassung; ein geänderter Host-Key sofort."
|
||||
>
|
||||
<Feldgruppe legende="Benachrichtigen bei" fehler={f["anlaesse"]}>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
{ANLAESSE.map((anlass) => (
|
||||
<Ankreuzfeld
|
||||
key={anlass}
|
||||
name="anlaesse"
|
||||
value={anlass}
|
||||
defaultChecked={ansicht.anlaesse[anlass]}
|
||||
beschriftung={ANLASS_TEXT[anlass].titel}
|
||||
erklaerung={ANLASS_TEXT[anlass].erklaerung}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Feldgruppe>
|
||||
</Karte>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function KanalStatus({ aktiv }: { aktiv: boolean }) {
|
||||
return aktiv ? <Badge variante="ok">aktiv</Badge> : <Badge>aus</Badge>;
|
||||
}
|
||||
|
||||
function Warnung({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p
|
||||
role="note"
|
||||
className="border-accent/40 bg-accent-soft text-accent rounded-box mt-2 border px-3 py-2 text-[13.5px]"
|
||||
>
|
||||
▲ {children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort bzw. Geheimnis: nie vorbelegt. Ist ein Wert gesetzt, zeigt das Feld nur „gesetzt“;
|
||||
* leer lassen behaelt ihn, das Ankreuzfeld entfernt ihn.
|
||||
*/
|
||||
function GeheimFeld({
|
||||
beschriftung,
|
||||
name,
|
||||
gesetzt,
|
||||
darfAendern,
|
||||
fehler,
|
||||
}: {
|
||||
beschriftung: string;
|
||||
name: string;
|
||||
gesetzt: boolean;
|
||||
darfAendern: boolean;
|
||||
fehler?: string | undefined;
|
||||
}) {
|
||||
if (!darfAendern) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<span className="text-muted text-[13px] font-medium">{beschriftung}</span>
|
||||
<span>{gesetzt ? <Badge variante="ok">gesetzt</Badge> : <Badge>nicht gesetzt</Badge>}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<Feld
|
||||
beschriftung={beschriftung}
|
||||
name={name}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
defaultValue=""
|
||||
placeholder={gesetzt ? "gesetzt – leer lassen, um es zu behalten" : "nicht gesetzt"}
|
||||
hinweis={
|
||||
gesetzt ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Badge variante="ok">gesetzt</Badge> Wird verschlüsselt gespeichert und nie wieder angezeigt.
|
||||
</span>
|
||||
) : (
|
||||
"Wird verschlüsselt gespeichert und nie wieder angezeigt."
|
||||
)
|
||||
}
|
||||
fehler={fehler}
|
||||
/>
|
||||
{gesetzt && (
|
||||
<Ankreuzfeld
|
||||
name={`${name}Entfernen`}
|
||||
value="ja"
|
||||
beschriftung={`${beschriftung.replace(/ \(optional\)$/, "")} entfernen`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestKnopf({ kanal }: { kanal: "EMAIL" | "WEBHOOK" }) {
|
||||
return (
|
||||
<div className="border-border flex flex-col gap-2 border-t pt-4">
|
||||
<p className="text-muted text-[13px]">
|
||||
Die Testnachricht nutzt den <strong className="text-text font-medium">gespeicherten</strong> Stand –
|
||||
erst speichern, dann testen. Der Versand läuft im Worker.
|
||||
</p>
|
||||
<VorgangsKnopf
|
||||
aktion={() => testSendenAktion(kanal)}
|
||||
beschriftung="Testnachricht senden"
|
||||
icon={<Send className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldAlert, ShieldCheck, ShieldQuestion } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { uhrzeit } from "@/lib/format";
|
||||
import { kettePruefenAktion, type KettenAntwort } from "@/server/actions/protokoll";
|
||||
|
||||
/**
|
||||
* Integritaet der Hash-Kette (#027). Geprueft wird nur auf Knopfdruck – die Pruefung liest
|
||||
* jede Zeile; ein Ergebnis der letzten 5 Minuten bringt die Seite schon mit.
|
||||
*/
|
||||
export function KettenPruefung({ anfang }: { anfang: KettenAntwort | null }) {
|
||||
const [ergebnis, setErgebnis] = useState<KettenAntwort | null>(anfang);
|
||||
const [laeuft, starten] = useTransition();
|
||||
|
||||
const pruefen = () =>
|
||||
starten(async () => {
|
||||
setErgebnis(await kettePruefenAktion());
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Status ergebnis={ergebnis} />
|
||||
<Button onClick={pruefen} disabled={laeuft} klein className="self-start sm:self-auto">
|
||||
{laeuft ? "Prüft …" : "Kette prüfen"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Status({ ergebnis }: { ergebnis: KettenAntwort | null }) {
|
||||
if (!ergebnis) {
|
||||
return (
|
||||
<p className="text-muted flex items-center gap-2 text-[14px]">
|
||||
<ShieldQuestion aria-hidden className="size-4 shrink-0" />
|
||||
Hash-Kette noch nicht geprüft
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (ergebnis.fehler) {
|
||||
return <p className="text-danger text-[14px]">{ergebnis.fehler}</p>;
|
||||
}
|
||||
const wann = ergebnis.geprueftAm ? ` um ${uhrzeit(ergebnis.geprueftAm)} Uhr` : "";
|
||||
if (!ergebnis.fehlerhafteId) {
|
||||
return (
|
||||
<p className="text-ok flex items-center gap-2 text-[14px] font-medium">
|
||||
<ShieldCheck aria-hidden className="size-4 shrink-0" />
|
||||
<span>
|
||||
Hash-Kette unversehrt
|
||||
<span className="text-muted font-normal">
|
||||
{" "}
|
||||
· {ergebnis.eintraege?.toLocaleString("de-DE")} Einträge geprüft{wann}
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const id = ergebnis.fehlerhafteId;
|
||||
// Die Seite beginnt beim fehlerhaften Eintrag, der Anker hebt ihn hervor.
|
||||
const ziel = `/protokoll?vor=${(BigInt(id) + 1n).toString()}#eintrag-${id}`;
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="border-danger-border bg-danger-soft text-danger rounded-box flex min-w-0 items-start gap-2.5 border px-3 py-2.5 text-[14px]"
|
||||
>
|
||||
<ShieldAlert aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||
<p className="min-w-0">
|
||||
<strong className="font-semibold">Hash-Kette beschädigt.</strong> Der Eintrag{" "}
|
||||
<a href={ziel} className="font-mono underline underline-offset-2">
|
||||
#{id}
|
||||
</a>{" "}
|
||||
stimmt nicht mit seinem Hash oder Vorgänger überein – ab hier ist das Protokoll nicht mehr belegbar.
|
||||
Vergleichen Sie mit der Syslog-Kopie und sichern Sie die Datenbank.{wann && ` Geprüft${wann}.`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { ChevronDown, History } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Badge, type BadgeVariante } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { DiffBox } from "@/components/ui/DiffBox";
|
||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||
import { aktionText, aktionTon, type AktionsTon } from "@/lib/protokoll-aktionen";
|
||||
import { diffZeilen, ERGEBNIS_TEXT, protokollZeit } from "@/lib/protokoll-filter";
|
||||
import { fingerprintKurz } from "@/lib/schluessel";
|
||||
import type { ProtokollSeite, ProtokollZeile } from "@/server/queries/protokoll";
|
||||
import { aeltereLadenAktion } from "@/server/actions/protokoll";
|
||||
|
||||
const TON_FARBE: Record<AktionsTon, string> = { ok: "text-ok", gefahr: "text-danger", neutral: "text-text" };
|
||||
const ERGEBNIS_BADGE: Record<ProtokollZeile["ergebnis"], BadgeVariante> = {
|
||||
ERFOLG: "neutral",
|
||||
FEHLER: "gefahr",
|
||||
VERWEIGERT: "gefahr",
|
||||
};
|
||||
|
||||
/**
|
||||
* Protokolleintraege als Tabelle (ab md) bzw. Karten (darunter), jede Zeile aufklappbar
|
||||
* (#027). „Ältere laden“ haengt die naechste Seite an, statt die Liste zu ersetzen – wer
|
||||
* eine Spur verfolgt, will das Neuere dabei nicht aus dem Blick verlieren.
|
||||
*/
|
||||
export function ProtokollListe({ seite, query }: { seite: ProtokollSeite; query: string }) {
|
||||
const [zeilen, setZeilen] = useState(seite.zeilen);
|
||||
const [weiterVor, setWeiterVor] = useState(seite.weiterVor);
|
||||
const [offen, setOffen] = useState<ReadonlySet<string>>(new Set());
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [laedt, starten] = useTransition();
|
||||
|
||||
const umschalten = (id: string) =>
|
||||
setOffen((vorher) => {
|
||||
const neu = new Set(vorher);
|
||||
if (neu.has(id)) neu.delete(id);
|
||||
else neu.add(id);
|
||||
return neu;
|
||||
});
|
||||
|
||||
const aeltereLaden = () => {
|
||||
if (!weiterVor) return;
|
||||
starten(async () => {
|
||||
const antwort = await aeltereLadenAktion(query, weiterVor);
|
||||
if ("fehler" in antwort) {
|
||||
setFehler(antwort.fehler);
|
||||
return;
|
||||
}
|
||||
setFehler(undefined);
|
||||
setZeilen((vorher) => [...vorher, ...antwort.zeilen]);
|
||||
setWeiterVor(antwort.weiterVor);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
{/* Unter md Karten statt der Tabelle: acht Spalten passen nicht auf 390 px. */}
|
||||
<ul className="flex flex-col md:hidden" aria-label="Protokolleinträge">
|
||||
{zeilen.map((z) => (
|
||||
<li key={z.id} className="border-border border-b py-3 last:border-b-0">
|
||||
<ZeileKompakt z={z} />
|
||||
<details className="group mt-1">
|
||||
<summary className="text-muted hover:text-text -mx-2 flex min-h-11 cursor-pointer list-none items-center gap-1.5 rounded-box px-2 text-[13px]">
|
||||
<ChevronDown aria-hidden className="size-4 transition-transform group-open:rotate-180" />
|
||||
Einzelheiten
|
||||
</summary>
|
||||
<Einzelheiten z={z} />
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Tabelle
|
||||
className="hidden md:block"
|
||||
beschriftung="Protokolleinträge"
|
||||
spalten={[
|
||||
{ titel: "Zeitpunkt" },
|
||||
{ titel: "Aktion" },
|
||||
{ titel: "Akteur" },
|
||||
{ titel: "Host" },
|
||||
{ titel: "Zielbenutzer" },
|
||||
{ titel: "Fingerprint" },
|
||||
{ titel: "Ergebnis" },
|
||||
{ titel: "Einzelheiten", versteckt: true, className: "w-12" },
|
||||
]}
|
||||
>
|
||||
{zeilen.map((z) => {
|
||||
const istOffen = offen.has(z.id);
|
||||
return <ZeilenPaar key={z.id} z={z} offen={istOffen} umschalten={() => umschalten(z.id)} />;
|
||||
})}
|
||||
</Tabelle>
|
||||
|
||||
{fehler && <p className="text-danger text-[13px]">{fehler}</p>}
|
||||
{weiterVor && (
|
||||
<div className="flex justify-center">
|
||||
<Button onClick={aeltereLaden} disabled={laedt} icon={<History className="size-4" />}>
|
||||
{laedt ? "Lädt …" : "Ältere laden"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AktionZelle({ z }: { z: ProtokollZeile }) {
|
||||
return (
|
||||
<span title={z.aktion} className={clsx("font-medium", TON_FARBE[aktionTon(z.aktion, z.ergebnis)])}>
|
||||
{aktionText(z.aktion)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ErgebnisBadge({ ergebnis }: { ergebnis: ProtokollZeile["ergebnis"] }) {
|
||||
return <Badge variante={ERGEBNIS_BADGE[ergebnis]}>{ERGEBNIS_TEXT[ergebnis]}</Badge>;
|
||||
}
|
||||
|
||||
function ZeilenPaar({ z, offen, umschalten }: { z: ProtokollZeile; offen: boolean; umschalten: () => void }) {
|
||||
const detailId = `eintrag-${z.id}-einzelheiten`;
|
||||
return (
|
||||
<>
|
||||
<TabellenZeile className={clsx(offen && "border-b-0")}>
|
||||
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||
{/* Anker fuer den Sprung aus der Kettenpruefung zur ersten fehlerhaften ID. */}
|
||||
<span
|
||||
id={`eintrag-${z.id}`}
|
||||
className="target:text-danger target:outline-danger scroll-mt-24 target:outline target:outline-offset-4"
|
||||
>
|
||||
{protokollZeit(z.zeitpunkt)}
|
||||
</span>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="min-w-[12rem]">
|
||||
<AktionZelle z={z} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">{z.akteurName}</TabellenZelle>
|
||||
<TabellenZelle mono className="whitespace-nowrap">
|
||||
{z.hostName ?? <Leer />}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle mono className="whitespace-nowrap">
|
||||
{z.zielbenutzer ?? <Leer />}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||
{z.fingerprint ? <span title={z.fingerprint}>{fingerprintKurz(z.fingerprint)}</span> : <Leer />}
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<ErgebnisBadge ergebnis={z.ergebnis} />
|
||||
</TabellenZelle>
|
||||
<TabellenZelle rechts className="w-12">
|
||||
<button
|
||||
type="button"
|
||||
onClick={umschalten}
|
||||
aria-expanded={offen}
|
||||
aria-controls={detailId}
|
||||
aria-label={`Einzelheiten zu Eintrag ${z.id}`}
|
||||
className="text-muted hover:text-text hover:bg-raised rounded-feld -my-2 inline-flex size-11 items-center justify-center transition-colors"
|
||||
>
|
||||
<ChevronDown aria-hidden className={clsx("size-4 transition-transform", offen && "rotate-180")} />
|
||||
</button>
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
{offen && (
|
||||
<tr id={detailId} className="border-border border-b last:border-b-0">
|
||||
<td colSpan={8} className="pb-4">
|
||||
<Einzelheiten z={z} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Leer() {
|
||||
return <span className="text-muted">–</span>;
|
||||
}
|
||||
|
||||
/** Kopf einer Karte in der mobilen Ansicht. */
|
||||
function ZeileKompakt({ z }: { z: ProtokollZeile }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted font-mono text-[12.5px]">{protokollZeit(z.zeitpunkt)}</span>
|
||||
<ErgebnisBadge ergebnis={z.ergebnis} />
|
||||
</div>
|
||||
<span className="text-[14.5px] leading-snug">
|
||||
<AktionZelle z={z} />
|
||||
</span>
|
||||
<span className="text-muted text-[13px] break-words">{z.akteurName}</span>
|
||||
{(z.hostName || z.zielbenutzer) && (
|
||||
<span className="text-text font-mono text-[12.5px] break-all">
|
||||
{z.zielbenutzer ? `${z.zielbenutzer}@` : ""}
|
||||
{z.hostName ?? "–"}
|
||||
</span>
|
||||
)}
|
||||
{z.fingerprint && (
|
||||
<span className="text-muted font-mono text-[12.5px] break-all">{fingerprintKurz(z.fingerprint)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Einzelheiten({ z }: { z: ProtokollZeile }) {
|
||||
return (
|
||||
<div className="bg-sidebar border-border rounded-box flex min-w-0 flex-col gap-4 border p-4">
|
||||
<dl className="grid min-w-0 gap-x-4 gap-y-2 text-[13px] sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||
<dt className="text-muted">ID</dt>
|
||||
<dd className="font-mono">{z.id}</dd>
|
||||
<dt className="text-muted">Aktion (technisch)</dt>
|
||||
<dd className="font-mono break-all">{z.aktion}</dd>
|
||||
{z.fingerprint && (
|
||||
<>
|
||||
<dt className="text-muted">Fingerprint</dt>
|
||||
<dd className="font-mono break-all">{z.fingerprint}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-muted">Hash</dt>
|
||||
<dd className="font-mono break-all" data-feld="hash">
|
||||
{z.hash}
|
||||
</dd>
|
||||
<dt className="text-muted">Vorgänger-Hash</dt>
|
||||
<dd className="font-mono break-all">{z.vorgaengerHash || "– (erster Eintrag)"}</dd>
|
||||
</dl>
|
||||
|
||||
{z.diff && (
|
||||
<section className="flex min-w-0 flex-col gap-2" aria-label="Diff">
|
||||
<h3 className="text-muted text-[13px] font-medium">Diff</h3>
|
||||
<DiffBox zeilen={diffZeilen(z.diff)} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{z.details && (
|
||||
<section className="flex min-w-0 flex-col gap-2" aria-label="Details">
|
||||
<h3 className="text-muted text-[13px] font-medium">Details</h3>
|
||||
<dl className="border-border bg-bg rounded-box grid min-w-0 gap-x-4 gap-y-1.5 border p-3 text-[12.5px] sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)]">
|
||||
{z.details.map((d) => (
|
||||
<div key={d.name} className="contents">
|
||||
<dt className="text-muted font-mono break-all">{d.name}</dt>
|
||||
<dd className="text-text mb-1.5 font-mono break-all whitespace-pre-wrap sm:mb-0">{d.wert}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { Ban, CircleCheck, CircleX, KeyRound, PlugZap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { Button, Formularfehler, Karte } from "@/components/ui";
|
||||
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||
import { warten } from "@/components/vorgang/warten";
|
||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||
import { RotationAbbrechen } from "./RotationAbbrechen";
|
||||
import {
|
||||
rotationAltWiderrufenAktion,
|
||||
rotationStartenAktion,
|
||||
rotationUebernehmenAktion,
|
||||
} from "@/server/actions/rotation";
|
||||
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
|
||||
import type { OffeneEntfernung } from "@/server/services/widerruf";
|
||||
|
||||
/**
|
||||
* Gefuehrte Rotation (#023) als Schrittfolge wie der Assistent (#020). Der Schritt kommt vom
|
||||
* Server (aus der Datenbank errechnet) – nach Neuladen, Abbruch oder Fehlschlag geht es an
|
||||
* derselben Stelle weiter.
|
||||
*/
|
||||
const SCHRITTE = [
|
||||
"Neuer Schlüssel",
|
||||
"Zuweisungen übernehmen",
|
||||
"Dry-Run & Ausrollen",
|
||||
"Verbindungstests",
|
||||
"Alten widerrufen",
|
||||
] as const;
|
||||
|
||||
export type RotationsTest = {
|
||||
id: string;
|
||||
ziel: string;
|
||||
ausgerollt: boolean;
|
||||
status: string | null;
|
||||
fehler: string | null;
|
||||
};
|
||||
|
||||
export type RotationsDaten = {
|
||||
alt: { id: string; name: string };
|
||||
neu: { id: string; name: string } | null;
|
||||
schritt: 0 | 1 | 2 | 3 | 4 | 5;
|
||||
neuOffen: number;
|
||||
tests: RotationsTest[];
|
||||
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
|
||||
widerruf: OffeneEntfernung[] | null;
|
||||
darfPrivat: boolean;
|
||||
/**
|
||||
* Abgebrochene Rotation (#032): `offen` sind die offenen Entfernungen des neuen Schluessels,
|
||||
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
|
||||
*/
|
||||
abbruch: { offen: OffeneEntfernung[] | null } | null;
|
||||
/** Zuletzt abgebrochener Nachfolger, wenn die Rotation danach wieder am Anfang steht (#033). */
|
||||
letzterAbbruch: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
export function Rotation({ daten }: { daten: RotationsDaten }) {
|
||||
const router = useRouter();
|
||||
const [laeuft, setLaeuft] = useState<string>();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const { alt, neu, schritt } = daten;
|
||||
|
||||
async function kette(arbeit: () => Promise<string | undefined>) {
|
||||
setFehler(undefined);
|
||||
try {
|
||||
const meldung = await arbeit();
|
||||
if (meldung) setFehler(meldung);
|
||||
} catch (f) {
|
||||
setFehler((f as Error).message);
|
||||
} finally {
|
||||
setLaeuft(undefined);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const vorbereiten = () =>
|
||||
kette(async () => {
|
||||
if (!neu) {
|
||||
setLaeuft("Neues Schlüsselpaar wird erzeugt …");
|
||||
const antwort = await rotationStartenAktion(alt.id);
|
||||
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
|
||||
const ende = await warten(antwort.vorgangId);
|
||||
if (ende.status !== "ERLEDIGT") return ende.fehler ?? "Erzeugen fehlgeschlagen.";
|
||||
}
|
||||
setLaeuft("Zuweisungen werden übernommen …");
|
||||
return (await rotationUebernehmenAktion(alt.id)).fehler;
|
||||
});
|
||||
|
||||
const testen = () =>
|
||||
kette(async () => {
|
||||
for (const test of daten.tests) {
|
||||
setLaeuft(`Teste ${test.ziel} …`);
|
||||
const antwort = await verbindungTestenAktion(test.id);
|
||||
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
|
||||
await warten(antwort.vorgangId);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const altWiderrufen = () =>
|
||||
kette(async () => {
|
||||
setLaeuft("Alter Schlüssel wird zum Entfernen vorgemerkt …");
|
||||
return (await rotationAltWiderrufenAktion(alt.id)).fehler;
|
||||
});
|
||||
|
||||
const status = (
|
||||
<>
|
||||
<Formularfehler text={fehler} />
|
||||
{laeuft && (
|
||||
<p role="status" className="text-muted text-[14px]">
|
||||
{laeuft}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (daten.abbruch?.offen && neu) {
|
||||
return (
|
||||
<Karte
|
||||
titel="Rotation wird abgebrochen"
|
||||
untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`}
|
||||
className="border-danger-border"
|
||||
>
|
||||
<WiderrufAblauf
|
||||
schluesselIds={[neu.id]}
|
||||
offen={daten.abbruch.offen}
|
||||
zielkonten={[]}
|
||||
vorauswahl={[]}
|
||||
ruecksprung={`/schluessel/${alt.id}/rotieren`}
|
||||
darfPrivat={daten.darfPrivat}
|
||||
ohneZielkonten
|
||||
/>
|
||||
</Karte>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{daten.letzterAbbruch && schritt === 0 && (
|
||||
<Karte titel="Rotation abgebrochen">
|
||||
<p className="text-[14px]">
|
||||
Der neue Schlüssel{" "}
|
||||
<Link href={`/schluessel/${daten.letzterAbbruch.id}`} className="text-accent hover:underline">
|
||||
{daten.letzterAbbruch.name}
|
||||
</Link>{" "}
|
||||
ist widerrufen und von allen Hosts entfernt.{" "}
|
||||
<Link href={`/schluessel/${alt.id}`} className="text-accent hover:underline">
|
||||
{alt.name}
|
||||
</Link>{" "}
|
||||
bleibt aktiv und lässt sich erneut rotieren.
|
||||
</p>
|
||||
</Karte>
|
||||
)}
|
||||
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
|
||||
|
||||
{schritt <= 1 && (
|
||||
<Karte
|
||||
titel={schritt === 0 ? "Neuen Schlüssel erzeugen" : "Zuweisungen übernehmen"}
|
||||
untertitel="Noch ändert sich auf keinem Host etwas."
|
||||
>
|
||||
<ul className="text-muted mb-4 list-disc space-y-1 pl-5 text-[14px]">
|
||||
<li>Neues Schlüsselpaar gleichen Typs, Name mit „(rotiert …)“, verknüpft mit dem alten.</li>
|
||||
<li>Public Key: dieselben Ziele und Einschränkungen wie bisher.</li>
|
||||
<li>
|
||||
Private Key: derselbe Host und Benutzer, neue Datei mit Suffix <code>-rJJJJMMTT</code>; der
|
||||
config-Eintrag behält seinen Alias und zeigt danach auf die neue Datei.
|
||||
</li>
|
||||
<li>Der alte Schlüssel bleibt gültig, bis der neue getestet ist.</li>
|
||||
</ul>
|
||||
{status}
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<KeyRound className="size-4" />}
|
||||
disabled={Boolean(laeuft)}
|
||||
onClick={() => void vorbereiten()}
|
||||
>
|
||||
{neu ? "Zuweisungen übernehmen" : "Neuen Schlüssel erzeugen und Zuweisungen übernehmen"}
|
||||
</Button>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 2 && neu && (
|
||||
<Karte
|
||||
titel="Neuen Schlüssel ausrollen"
|
||||
untertitel="Dry-Run prüfen, dann anwenden – der alte Schlüssel bleibt vorerst daneben bestehen."
|
||||
>
|
||||
<AusrollenPanel auswahl={{ schluesselId: neu.id }} offen={daten.neuOffen} />
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 3 && (
|
||||
<Karte
|
||||
titel="Verbindungen mit dem neuen Schlüssel testen"
|
||||
untertitel="Erst wenn alle Tests grün sind, wird der alte Schlüssel entfernt."
|
||||
>
|
||||
<ul className="mb-4 flex flex-col gap-2">
|
||||
{daten.tests.map((t) => (
|
||||
<li key={t.id} className="flex flex-col gap-0.5 text-[14px]">
|
||||
<span className="flex items-center gap-2 font-mono text-[13px] break-all">
|
||||
{t.status === "ERLEDIGT" ? (
|
||||
<CircleCheck className="text-ok size-4 shrink-0" aria-label="grün" />
|
||||
) : t.status === "FEHLGESCHLAGEN" ? (
|
||||
<CircleX className="text-danger size-4 shrink-0" aria-label="fehlgeschlagen" />
|
||||
) : (
|
||||
<PlugZap className="text-muted size-4 shrink-0" aria-label="noch nicht getestet" />
|
||||
)}
|
||||
{t.ziel}
|
||||
</span>
|
||||
{t.fehler && <span className="text-danger pl-6 text-[13px]">{t.fehler}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{status}
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<PlugZap className="size-4" />}
|
||||
disabled={Boolean(laeuft)}
|
||||
onClick={() => void testen()}
|
||||
>
|
||||
Verbindungen testen
|
||||
</Button>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 4 && !daten.widerruf && (
|
||||
<Karte
|
||||
titel="Alten Schlüssel entfernen"
|
||||
untertitel={
|
||||
daten.tests.length > 0
|
||||
? "Alle Verbindungstests sind grün."
|
||||
: "Keine Private-Key-Zuweisung mit Alias – es gibt nichts zu testen."
|
||||
}
|
||||
>
|
||||
<p className="text-muted mb-4 text-[14px]">
|
||||
Der alte Schlüssel „{alt.name}“ wird wie bei einem Widerruf von allen Hosts entfernt (Dry-Run
|
||||
folgt). Danach gilt er als rotiert.
|
||||
</p>
|
||||
{status}
|
||||
<Button
|
||||
variante="gefahr"
|
||||
icon={<Ban className="size-4" />}
|
||||
disabled={Boolean(laeuft)}
|
||||
onClick={() => void altWiderrufen()}
|
||||
>
|
||||
Alten Schlüssel widerrufen
|
||||
</Button>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 4 && daten.widerruf && (
|
||||
<Karte
|
||||
titel="Alten Schlüssel entfernen"
|
||||
untertitel="Der Dry-Run zeigt, was vom alten Schlüssel verschwindet."
|
||||
className="border-danger-border"
|
||||
>
|
||||
<WiderrufAblauf
|
||||
schluesselIds={[alt.id]}
|
||||
offen={daten.widerruf}
|
||||
zielkonten={[]}
|
||||
vorauswahl={[]}
|
||||
ruecksprung={`/schluessel/${alt.id}/rotieren`}
|
||||
darfPrivat={daten.darfPrivat}
|
||||
ohneZielkonten
|
||||
/>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{/* Abbrechen geht, solange der alte Schluessel noch nicht entfernt wird (#032). */}
|
||||
{neu && schritt >= 1 && schritt <= 4 && !daten.widerruf && (
|
||||
<RotationAbbrechen altId={alt.id} altName={alt.name} neuName={neu.name} />
|
||||
)}
|
||||
|
||||
{schritt === 5 && neu && (
|
||||
<Karte titel="Rotation abgeschlossen">
|
||||
<p className="text-[14px]">
|
||||
Der alte Schlüssel ist von allen Hosts entfernt. Weiter mit{" "}
|
||||
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
|
||||
{neu.name}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Karte>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { Undo2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button, Formularfehler } from "@/components/ui";
|
||||
import { rotationAbbrechenAktion } from "@/server/actions/rotation";
|
||||
|
||||
/**
|
||||
* „Rotation abbrechen“ (#032) – auf der Rotationsseite und auf der Seite des Nachfolgers. Der
|
||||
* Klick merkt den neuen Schluessel nur zum Widerruf vor; entfernt wird er erst nach Dry-Run und
|
||||
* „Anwenden“. Deshalb erst eine Rueckfrage, die genau das sagt.
|
||||
*/
|
||||
export function RotationAbbrechen({
|
||||
altId,
|
||||
altName,
|
||||
neuName,
|
||||
}: {
|
||||
altId: string;
|
||||
altName: string;
|
||||
neuName: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [offen, setOffen] = useState(false);
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [laeuft, starten] = useTransition();
|
||||
|
||||
if (!offen) {
|
||||
return (
|
||||
<div>
|
||||
<Button icon={<Undo2 className="size-4" />} onClick={() => setOffen(true)}>
|
||||
Rotation abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Rotation abbrechen?"
|
||||
className="border-danger-border bg-bg rounded-box flex flex-col gap-3 border px-4 py-3 text-[14px]"
|
||||
>
|
||||
<p className="font-medium">Rotation abbrechen?</p>
|
||||
<ul className="text-muted list-disc space-y-1 pl-5 text-[13.5px]">
|
||||
<li>
|
||||
Der neue Schlüssel „{neuName}“ wird widerrufen – wo er schon ausgerollt ist, zeigt der Dry-Run, was
|
||||
entfernt wird.
|
||||
</li>
|
||||
<li>„{altName}“ bleibt aktiv und unverändert; ein config-Alias zeigt danach wieder auf ihn.</li>
|
||||
<li>Die Verknüpfung bleibt in der Rotationskette sichtbar, der Abbruch steht im Protokoll.</li>
|
||||
</ul>
|
||||
<Formularfehler text={fehler} />
|
||||
<div className="flex flex-wrap justify-end gap-2.5">
|
||||
<Button disabled={laeuft} onClick={() => setOffen(false)}>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variante="gefahr"
|
||||
icon={<Undo2 className="size-4" />}
|
||||
disabled={laeuft}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
const antwort = await rotationAbbrechenAktion(altId);
|
||||
if (antwort.fehler) {
|
||||
setFehler(antwort.fehler);
|
||||
return;
|
||||
}
|
||||
setOffen(false);
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuft ? "Wird vorgemerkt …" : "Neuen Schlüssel widerrufen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export function Karte({
|
||||
className,
|
||||
innen = "normal",
|
||||
as: Element = "section",
|
||||
id,
|
||||
}: {
|
||||
titel?: ReactNode;
|
||||
untertitel?: ReactNode;
|
||||
@@ -22,9 +23,12 @@ export function Karte({
|
||||
/** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */
|
||||
innen?: "normal" | "ohne";
|
||||
as?: "section" | "div" | "article";
|
||||
/** Sprungziel für Links von anderen Seiten (z. B. `#dry-run`, #032). */
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<Element
|
||||
id={id}
|
||||
className={clsx(
|
||||
"border-border bg-surface min-w-0 rounded-karte border",
|
||||
innen === "normal" && "p-5 sm:p-6",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
/**
|
||||
* Schrittanzeige fuer gefuehrte Ablaeufe (Assistent #020, Widerruf #024, Rotation #023):
|
||||
* erledigte Schritte mit Haken, der aktuelle in Akzentfarbe und als `aria-current="step"`.
|
||||
*/
|
||||
export function Schrittleiste({
|
||||
schritte,
|
||||
aktuell,
|
||||
beschriftung = "Schritte",
|
||||
}: {
|
||||
schritte: readonly string[];
|
||||
/** Index des aktuellen Schritts; `schritte.length` heisst: alles erledigt. */
|
||||
aktuell: number;
|
||||
beschriftung?: string;
|
||||
}) {
|
||||
return (
|
||||
<ol className="flex flex-wrap gap-2" aria-label={beschriftung}>
|
||||
{schritte.map((titel, index) => (
|
||||
<li
|
||||
key={titel}
|
||||
aria-current={index === aktuell ? "step" : undefined}
|
||||
className={clsx(
|
||||
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
|
||||
index === aktuell
|
||||
? "border-accent text-accent bg-raised font-medium"
|
||||
: index < aktuell
|
||||
? "border-border text-text"
|
||||
: "border-border text-muted",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{index < aktuell ? <Check className="size-4" aria-hidden /> : index + 1}
|
||||
</span>
|
||||
{titel}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Wartet auf das Ende eines Vorgangs – fuer Ketten im Klick (Assistent #020, Widerruf #024,
|
||||
* Rotation #023), nicht als Hook.
|
||||
*/
|
||||
export type VorgangsEnde = { status: string; fehler: string | null; ergebnis: unknown };
|
||||
|
||||
export async function warten(vorgangId: string): Promise<VorgangsEnde> {
|
||||
for (;;) {
|
||||
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
|
||||
const stand = (await antwort.json()) as VorgangsEnde;
|
||||
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
|
||||
await new Promise((weiter) => setTimeout(weiter, 800));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* „Alle auswählen“ fuer den Massenwiderruf (#024): setzt alle aktivierbaren Kaestchen, die zum
|
||||
* Formular gehoeren. Die Auswahl selbst bleibt ein schlichtes GET-Formular – ohne JavaScript
|
||||
* waehlt man eben einzeln.
|
||||
*/
|
||||
export function AlleAuswaehlen({ formular }: { formular: string }) {
|
||||
return (
|
||||
<label className="hover:bg-raised/60 rounded-box flex min-h-11 cursor-pointer items-center gap-3 px-2 text-[14px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent size-[18px] cursor-pointer"
|
||||
onChange={(e) => {
|
||||
const kaestchen = document.querySelectorAll<HTMLInputElement>(
|
||||
`input[type="checkbox"][form="${formular}"]:not(:disabled)`,
|
||||
);
|
||||
for (const k of kaestchen) k.checked = e.target.checked;
|
||||
}}
|
||||
/>
|
||||
Alle auswählen
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCheck, ExternalLink } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
|
||||
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||
import { warten } from "@/components/vorgang/warten";
|
||||
import { widerrufAbschliessenAktion, widerrufZielkontenAktion } from "@/server/actions/widerruf";
|
||||
import type { OffeneEntfernung, ZielkontoKandidat } from "@/server/services/widerruf";
|
||||
|
||||
/**
|
||||
* Ein eingeleiteter Widerruf (#024), wiederaufnehmbar wie der Assistent (#020): Dry-Run und
|
||||
* Anwenden fuer alle offenen Entfernungen, dann optional Zielkonten, dann Abschluss.
|
||||
*
|
||||
* Was noch fehlt, kommt immer frisch vom Server (`offen`) – nach einem Neuladen, einem
|
||||
* Fehlschlag auf einem Host oder einer erneuten Bestaetigung geht es an derselben Stelle weiter.
|
||||
*/
|
||||
const SCHRITTE = ["Vorgemerkt", "Dry-Run & Anwenden", "Zielkonten", "Abgeschlossen"] as const;
|
||||
|
||||
const ZUSTAND_TEXT: Record<string, string> = {
|
||||
ENTFERNEN: "steht aus",
|
||||
FEHLGESCHLAGEN: "letzter Versuch fehlgeschlagen",
|
||||
};
|
||||
|
||||
export function WiderrufAblauf({
|
||||
schluesselIds,
|
||||
offen,
|
||||
zielkonten,
|
||||
vorauswahl,
|
||||
ruecksprung,
|
||||
darfPrivat,
|
||||
ohneZielkonten = false,
|
||||
nachAbschluss,
|
||||
}: {
|
||||
schluesselIds: string[];
|
||||
offen: OffeneEntfernung[];
|
||||
zielkonten: ZielkontoKandidat[];
|
||||
vorauswahl: string[];
|
||||
/** Diese Seite – Ziel der erneuten Bestaetigung. */
|
||||
ruecksprung: string;
|
||||
darfPrivat: boolean;
|
||||
/** Rotation (#023): das Zielkonto nutzt der Nachfolger weiter. */
|
||||
ohneZielkonten?: boolean;
|
||||
nachAbschluss?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const waehlbar = ohneZielkonten ? [] : zielkonten.filter((k) => k.nochGenutztVon.length === 0);
|
||||
// Ohne waehlbares Zielkonto entfaellt der Schritt ganz – ein leerer Schritt verwirrt nur.
|
||||
const ohneSchritt = waehlbar.length === 0;
|
||||
const [auswahl, setAuswahl] = useState<string[]>(() =>
|
||||
vorauswahl.filter((id) => waehlbar.some((k) => k.id === id)),
|
||||
);
|
||||
const [zielkontenFertig, setZielkontenFertig] = useState(false);
|
||||
const [laeuft, setLaeuft] = useState<string>();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [bestaetigen, setBestaetigen] = useState(false);
|
||||
const [noch, setNoch] = useState<OffeneEntfernung[]>();
|
||||
|
||||
const aktuell = offen.length > 0 ? 1 : auswahl.length > 0 && !zielkontenFertig ? 2 : 3;
|
||||
const privatOhneRecht = !darfPrivat && offen.some((o) => o.art === "privat");
|
||||
|
||||
async function abschliessen() {
|
||||
setFehler(undefined);
|
||||
setBestaetigen(false);
|
||||
setNoch(undefined);
|
||||
try {
|
||||
if (auswahl.length > 0 && !zielkontenFertig) {
|
||||
setLaeuft("Zielkonten werden entfernt …");
|
||||
const antwort = await widerrufZielkontenAktion(schluesselIds, auswahl);
|
||||
if (antwort.bestaetigen) setBestaetigen(true);
|
||||
if (antwort.fehler || !antwort.vorgaenge) {
|
||||
setFehler(antwort.fehler);
|
||||
return;
|
||||
}
|
||||
const fehlschlaege: string[] = [];
|
||||
for (const vorgang of antwort.vorgaenge) {
|
||||
const ende = await warten(vorgang.id);
|
||||
if (ende.status !== "ERLEDIGT")
|
||||
fehlschlaege.push(`${vorgang.beschriftung}: ${ende.fehler ?? "fehlgeschlagen"}`);
|
||||
}
|
||||
if (fehlschlaege.length > 0) {
|
||||
setFehler(`Zielkonto nicht entfernt – ${fehlschlaege.join(" · ")}`);
|
||||
return;
|
||||
}
|
||||
setZielkontenFertig(true);
|
||||
}
|
||||
setLaeuft("Widerruf wird abgeschlossen …");
|
||||
const antwort = await widerrufAbschliessenAktion(schluesselIds);
|
||||
if (antwort.fehler) {
|
||||
setFehler(antwort.fehler);
|
||||
return;
|
||||
}
|
||||
const offeneSchluessel = (antwort.ergebnisse ?? []).filter((e) => e.status === "OFFEN");
|
||||
if (offeneSchluessel.length > 0) {
|
||||
setNoch(offeneSchluessel.flatMap((e) => e.offen));
|
||||
} else {
|
||||
nachAbschluss?.();
|
||||
}
|
||||
router.refresh();
|
||||
} catch (fehler) {
|
||||
setFehler((fehler as Error).message);
|
||||
} finally {
|
||||
setLaeuft(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const bestaetigenZiel = (() => {
|
||||
const url = new URL(ruecksprung, "http://x");
|
||||
if (auswahl.length > 0) url.searchParams.set("zielkonten", auswahl.join(","));
|
||||
else url.searchParams.delete("zielkonten");
|
||||
return `${url.pathname}${url.search}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Schrittleiste
|
||||
schritte={ohneSchritt ? SCHRITTE.filter((s) => s !== "Zielkonten") : SCHRITTE}
|
||||
aktuell={ohneSchritt && aktuell >= 2 ? aktuell - 1 : aktuell}
|
||||
beschriftung="Schritte des Widerrufs"
|
||||
/>
|
||||
|
||||
{offen.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-[14px] font-medium">Noch nicht entfernt ({offen.length}):</p>
|
||||
<ul className="flex flex-col gap-1 font-mono text-[13px]">
|
||||
{offen.map((o) => (
|
||||
<li key={o.id} className="break-all">
|
||||
<span className={o.zustand === "FEHLGESCHLAGEN" ? "text-danger" : "text-accent"}>●</span>{" "}
|
||||
{o.ziel}
|
||||
<span className="text-muted font-sans">
|
||||
{" "}
|
||||
· {ZUSTAND_TEXT[o.zustand] ?? o.zustand.toLowerCase()}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{privatOhneRecht ? (
|
||||
<p className="text-danger text-[14px]">Private Keys entfernt nur ein Admin.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted text-[13.5px]">
|
||||
Der Dry-Run zeigt je Host, was entfernt wird – und warum ein Host gerade nicht geht (etwa
|
||||
nicht erreichbar). Fehlgeschlagene Ziele bleiben offen; ein neuer Dry-Run versucht es erneut.
|
||||
</p>
|
||||
<AusrollenPanel
|
||||
auswahl={schluesselIds.length === 1 ? { schluesselId: schluesselIds[0]! } : { schluesselIds }}
|
||||
offen={offen.length}
|
||||
nachAusrollen={() => void abschliessen()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{waehlbar.length > 0 && (
|
||||
<fieldset className="flex flex-col gap-1">
|
||||
<legend className="mb-1 text-[14px] font-medium">Zielkonten entfernen (optional)</legend>
|
||||
{zielkonten.map((k) => {
|
||||
const genutzt = k.nochGenutztVon.length > 0;
|
||||
return (
|
||||
<Ankreuzfeld
|
||||
key={k.id}
|
||||
disabled={genutzt || zielkontenFertig || Boolean(laeuft)}
|
||||
checked={auswahl.includes(k.id)}
|
||||
onChange={(e) =>
|
||||
setAuswahl((alt) => (e.target.checked ? [...alt, k.id] : alt.filter((id) => id !== k.id)))
|
||||
}
|
||||
beschriftung={
|
||||
<>
|
||||
Benutzer <span className="font-mono">{k.benutzername}</span> auf{" "}
|
||||
<span className="font-mono">{k.hostName}</span> entfernen
|
||||
</>
|
||||
}
|
||||
erklaerung={
|
||||
genutzt
|
||||
? `Bleibt: wird noch von „${k.nochGenutztVon.join("“, „")}“ genutzt.`
|
||||
: "Nach dem Entfernen der Schlüssel; verlangt eine frische Bestätigung."
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{noch && noch.length > 0 && (
|
||||
<p
|
||||
role="status"
|
||||
className="border-danger-border bg-danger-soft text-danger rounded-box border px-4 py-3 text-[14px]"
|
||||
>
|
||||
Noch nicht widerrufen – offen: {noch.map((o) => o.ziel).join(", ")}. Bitte einen neuen Dry-Run
|
||||
starten.
|
||||
</p>
|
||||
)}
|
||||
<Formularfehler text={fehler} />
|
||||
{bestaetigen && (
|
||||
<a
|
||||
href={`/bestaetigen?ziel=${encodeURIComponent(bestaetigenZiel)}`}
|
||||
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
|
||||
>
|
||||
<ExternalLink className="size-4" aria-hidden /> Anmeldung bestätigen, danach hier fortsetzen
|
||||
</a>
|
||||
)}
|
||||
{laeuft && (
|
||||
<p role="status" className="text-muted text-[14px]">
|
||||
{laeuft}
|
||||
</p>
|
||||
)}
|
||||
{offen.length === 0 && (
|
||||
<div>
|
||||
<Button
|
||||
variante="gefahr"
|
||||
icon={<CheckCheck className="size-4" />}
|
||||
disabled={Boolean(laeuft)}
|
||||
onClick={() => void abschliessen()}
|
||||
>
|
||||
Widerruf abschließen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { Ban } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
|
||||
import { widerrufEinleitenAktion } from "@/server/actions/widerruf";
|
||||
import type { WiderrufUebersicht } from "@/server/queries/widerruf";
|
||||
|
||||
/**
|
||||
* Bestaetigung eines Widerrufs (#024): listet genau auf, was auf welchem Host passiert, und
|
||||
* laesst Zielkonten (Standard: aus) zum Entfernen auswaehlen. Der Klick merkt nur vor – auf den
|
||||
* Hosts aendert sich erst nach Dry-Run und „Anwenden“ etwas.
|
||||
*
|
||||
* Die Auswahl der Zielkonten reist als `?zielkonten=` in der Adresse weiter: so uebersteht sie
|
||||
* ein Neuladen und die erneute Bestaetigung der Anmeldung.
|
||||
*/
|
||||
export function WiderrufFormular({
|
||||
uebersicht,
|
||||
ruecksprung,
|
||||
abbrechen,
|
||||
}: {
|
||||
uebersicht: WiderrufUebersicht;
|
||||
/** Wohin nach dem Vormerken (Schluesselseite bzw. Massenwiderruf-Seite). */
|
||||
ruecksprung: string;
|
||||
abbrechen?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [auswahl, setAuswahl] = useState<string[]>([]);
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const [bestaetigen, setBestaetigen] = useState(false);
|
||||
const [laeuft, starten] = useTransition();
|
||||
const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt);
|
||||
const mehrere = uebersicht.schluessel.length > 1;
|
||||
const knopfText = mehrere ? `${uebersicht.schluessel.length} Schlüssel widerrufen` : "Widerruf vorbereiten";
|
||||
|
||||
const ziel = (() => {
|
||||
const url = new URL(ruecksprung, "http://x");
|
||||
if (auswahl.length > 0) url.searchParams.set("zielkonten", auswahl.join(","));
|
||||
return `${url.pathname}${url.search}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<WiderrufInhalt uebersicht={uebersicht} mehrere={mehrere} />
|
||||
|
||||
{uebersicht.zielkonten.length > 0 && (
|
||||
<fieldset className="flex flex-col gap-1">
|
||||
<legend className="mb-1 text-[14px] font-medium">Zielkonten zusätzlich entfernen (optional)</legend>
|
||||
{uebersicht.zielkonten.map((k) => {
|
||||
const genutzt = k.nochGenutztVon.length > 0;
|
||||
return (
|
||||
<Ankreuzfeld
|
||||
key={k.id}
|
||||
name="zielkonten"
|
||||
value={k.id}
|
||||
disabled={genutzt}
|
||||
checked={auswahl.includes(k.id)}
|
||||
onChange={(e) =>
|
||||
setAuswahl((alt) => (e.target.checked ? [...alt, k.id] : alt.filter((id) => id !== k.id)))
|
||||
}
|
||||
beschriftung={
|
||||
<>
|
||||
Benutzer <span className="font-mono">{k.benutzername}</span> auf{" "}
|
||||
<span className="font-mono">{k.hostName}</span> entfernen
|
||||
</>
|
||||
}
|
||||
erklaerung={
|
||||
genutzt
|
||||
? `Bleibt: wird noch von „${k.nochGenutztVon.join("“, „")}“ genutzt.`
|
||||
: `Von sshkv angelegt. Benutzer samt Home${k.sudoModus !== "KEINER" ? " und sudoers-Datei" : ""} werden gelöscht – verlangt eine frische Bestätigung.`
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<div className="border-border bg-bg rounded-box border px-4 py-3 text-[13.5px]">
|
||||
<p className="mb-1.5 font-medium">So geht es weiter</p>
|
||||
<ol className="text-muted list-decimal space-y-1 pl-5">
|
||||
<li>
|
||||
Die Zuweisungen werden zum Entfernen vorgemerkt; der Schlüssel lässt sich nicht mehr zuweisen.
|
||||
</li>
|
||||
<li>Der Dry-Run zeigt jede Änderung. Erst „Anwenden“ ändert etwas auf den Hosts.</li>
|
||||
<li>
|
||||
Erst wenn alle Entfernungen erfolgreich waren, gilt der Schlüssel als widerrufen. Scheitert ein
|
||||
Host, bleibt er im Zwischenzustand und die Seite zeigt, was fehlt.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{gesperrt.map((s) => (
|
||||
<p key={s.id} className="text-danger text-[14px]">
|
||||
{s.gesperrt}
|
||||
</p>
|
||||
))}
|
||||
<Formularfehler text={fehler} />
|
||||
{bestaetigen && (
|
||||
<a
|
||||
href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`}
|
||||
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium"
|
||||
>
|
||||
{/* Nennt den Knopf, den es hier wirklich gibt – beim Massenwiderruf heisst er anders (#032). */}
|
||||
Anmeldung bestätigen und danach erneut „{knopfText}“ wählen
|
||||
</a>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-end gap-2.5">
|
||||
{abbrechen && <Button onClick={abbrechen}>Abbrechen</Button>}
|
||||
<Button
|
||||
variante="gefahr"
|
||||
icon={<Ban className="size-4" />}
|
||||
disabled={laeuft || gesperrt.length > 0}
|
||||
onClick={() =>
|
||||
starten(async () => {
|
||||
setFehler(undefined);
|
||||
const antwort = await widerrufEinleitenAktion(uebersicht.schluessel.map((s) => s.id));
|
||||
if (antwort.bestaetigen) setBestaetigen(true);
|
||||
if (antwort.fehler) {
|
||||
setFehler(antwort.fehler);
|
||||
return;
|
||||
}
|
||||
abbrechen?.();
|
||||
router.replace(ziel);
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
{laeuft ? "Wird vorgemerkt …" : knopfText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Die Liste „was passiert“ – im Dialog und auf der Massenwiderruf-Seite. */
|
||||
export function WiderrufInhalt({
|
||||
uebersicht,
|
||||
mehrere,
|
||||
}: {
|
||||
uebersicht: WiderrufUebersicht;
|
||||
mehrere: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{uebersicht.schluessel.map((s) => {
|
||||
const oeffentlich = uebersicht.public.filter((v) => v.schluesselId === s.id);
|
||||
const privat = uebersicht.privat.filter((v) => v.schluesselId === s.id);
|
||||
return (
|
||||
<section key={s.id} className="flex flex-col gap-2" aria-label={`Widerruf von ${s.name}`}>
|
||||
{mehrere && (
|
||||
<h3 className="text-[14.5px] font-semibold break-words">
|
||||
{s.name}
|
||||
{s.person && <span className="text-muted font-normal"> · {s.person}</span>}
|
||||
</h3>
|
||||
)}
|
||||
{oeffentlich.length === 0 && privat.length === 0 && (
|
||||
<p className="text-muted text-[13.5px]">
|
||||
Keine Zuweisungen – der Schlüssel wird ohne Änderung auf einem Host widerrufen.
|
||||
</p>
|
||||
)}
|
||||
{oeffentlich.length > 0 && (
|
||||
<div>
|
||||
<p className="text-muted mb-1 text-[13px]">Public Key wird aus authorized_keys entfernt:</p>
|
||||
<ul className="flex flex-col gap-1 font-mono text-[13px]">
|
||||
{oeffentlich.map((v) => (
|
||||
<li key={v.id} className="break-all">
|
||||
<span className="text-danger">−</span> {v.ziel}
|
||||
{v.archiviert && (
|
||||
<span className="text-muted font-sans">
|
||||
{" "}
|
||||
· Host archiviert – sshkv ändert dort nichts
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{privat.length > 0 && (
|
||||
<div>
|
||||
<p className="text-muted mb-1 text-[13px]">Private Key wird vom Quell-Host entfernt:</p>
|
||||
<ul className="flex flex-col gap-1.5 font-mono text-[13px]">
|
||||
{privat.map((v) => (
|
||||
<li key={v.id}>
|
||||
<span className="break-all">
|
||||
<span className="text-danger">−</span> {v.ziel}:{v.pfad}
|
||||
</span>
|
||||
<span className="text-muted block pl-3.5 font-sans text-[12.5px]">
|
||||
{v.beimWiderruf === "UMBENENNEN"
|
||||
? `Wird umbenannt in …${v.pfad.split("/").pop()}.sshkv-widerrufen.<Zeit>`
|
||||
: "Wird gelöscht"}
|
||||
, dazu die .pub-Datei
|
||||
{v.alias ? `, der config-Eintrag „Host ${v.alias}“ und sein known_hosts-Block` : ""}
|
||||
{v.archiviert ? " · Host archiviert – sshkv ändert dort nichts" : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { Ban, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import type { WiderrufUebersicht } from "@/server/queries/widerruf";
|
||||
import { WiderrufFormular } from "./WiderrufFormular";
|
||||
|
||||
/**
|
||||
* „Widerrufen“ auf der Schluesselseite (#024): Gefahr-Knopf mit Bestaetigungsdialog. Offen/zu
|
||||
* als Zustand wie im AusrollDialog – kein Render-Pfad liest die Ref.
|
||||
*/
|
||||
export function WiderrufKnopf({
|
||||
uebersicht,
|
||||
ruecksprung,
|
||||
beschriftung = "Widerrufen",
|
||||
}: {
|
||||
uebersicht: WiderrufUebersicht;
|
||||
ruecksprung: string;
|
||||
beschriftung?: string;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [offen, setOffen] = useState(false);
|
||||
const name = uebersicht.schluessel[0]?.name ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
if (!element) return;
|
||||
if (offen && !element.open) element.showModal();
|
||||
if (!offen && element.open) element.close();
|
||||
}, [offen]);
|
||||
|
||||
const schliessen = () => setOffen(false);
|
||||
return (
|
||||
<>
|
||||
<Button variante="gefahr" icon={<Ban className="size-4" />} onClick={() => setOffen(true)}>
|
||||
{beschriftung}
|
||||
</Button>
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-labelledby="widerruf-titel"
|
||||
onClose={schliessen}
|
||||
className="border-border-strong bg-surface text-text rounded-karte m-auto max-h-[calc(100dvh-32px)] w-[calc(100%-32px)] max-w-2xl overflow-y-auto border p-5 backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<h2 id="widerruf-titel" className="font-display text-[18px] font-semibold break-words">
|
||||
„{name}“ widerrufen?
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={schliessen}
|
||||
aria-label="Schließen"
|
||||
className="text-muted hover:text-text hover:bg-raised rounded-feld -mt-2 -mr-2 flex size-11 shrink-0 items-center justify-center"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
{offen && (
|
||||
<WiderrufFormular uebersicht={uebersicht} ruecksprung={ruecksprung} abbrechen={schliessen} />
|
||||
)}
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||
import { PublicZuweisenFormular } from "@/components/ausrollen/PublicZuweisenFormular";
|
||||
import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { InnereBox } from "@/components/ui/Karte";
|
||||
import { KENNUNG_TEXT, type MatrixHost, type MatrixSchluessel, type Zelle } from "@/lib/zuweisungsmatrix";
|
||||
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||
|
||||
export type ZellenAuswahl = { readonly schluesselId: string; readonly hostId: string | null };
|
||||
|
||||
/**
|
||||
* Detail einer Matrixzelle (#025): was dort verwaltet ist, Entfernen und Zuweisen mit den
|
||||
* vorhandenen Aktionen. Ausgerollt wird weiter nur ueber den Dry-Run der Schluesselseite –
|
||||
* die Matrix plant, sie schreibt nichts auf Hosts.
|
||||
*
|
||||
* Die Zelle kommt aus den Props: nach `router.refresh()` zeigt der offene Dialog sofort den
|
||||
* neuen Stand, ohne eigenen Zustand nachzufuehren.
|
||||
*/
|
||||
export function ZellenDialog({
|
||||
schluessel,
|
||||
host,
|
||||
zelle,
|
||||
darfZuweisen,
|
||||
darfPrivatEntfernen,
|
||||
auswahl,
|
||||
schliessen,
|
||||
}: {
|
||||
schluessel: MatrixSchluessel;
|
||||
host: MatrixHost | undefined;
|
||||
zelle: Zelle | undefined;
|
||||
darfZuweisen: boolean;
|
||||
darfPrivatEntfernen: boolean;
|
||||
auswahl?: ZielAuswahl;
|
||||
schliessen: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const router = useRouter();
|
||||
// Nach erfolgreichem Zuweisen ein frisches Formular – sonst schickt ein zweiter Klick
|
||||
// dieselbe Zuweisung noch einmal und erntet „bereits zugewiesen“.
|
||||
const [formularNr, setFormularNr] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const element = dialog.current;
|
||||
if (element && !element.open) element.showModal();
|
||||
}, []);
|
||||
|
||||
const titel = host ? `${schluessel.name} auf ${host.name}` : schluessel.name;
|
||||
const vorauswahl = host && auswahl?.hosts.some((h) => h.id === host.id) ? `host:${host.id}` : undefined;
|
||||
const eintraege = zelle?.eintraege ?? [];
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-label={titel}
|
||||
onClose={schliessen}
|
||||
className="border-border-strong bg-surface text-text rounded-karte m-auto max-h-[calc(100dvh-32px)] w-[calc(100%-32px)] overflow-y-auto overscroll-contain max-w-xl border p-5 backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display text-[18px] font-semibold break-words">{schluessel.name}</h2>
|
||||
<p className="text-muted mt-0.5 text-[13.5px]">
|
||||
{host ? (
|
||||
<>
|
||||
auf <span className="font-mono">{host.name}</span>
|
||||
</>
|
||||
) : (
|
||||
"Einem Host oder einer Hostgruppe zuweisen"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={schliessen}
|
||||
aria-label="Schließen"
|
||||
className="text-muted hover:text-text hover:bg-raised rounded-feld -mt-2 -mr-2 flex size-11 shrink-0 items-center justify-center"
|
||||
>
|
||||
<X className="size-5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-[13.5px]">
|
||||
<Link
|
||||
href={`/schluessel/${schluessel.id}`}
|
||||
className="text-accent inline-flex min-h-11 items-center hover:underline"
|
||||
>
|
||||
Zum Schlüssel
|
||||
</Link>
|
||||
{host && (
|
||||
<Link
|
||||
href={`/hosts/${host.id}`}
|
||||
className="text-accent inline-flex min-h-11 items-center hover:underline"
|
||||
>
|
||||
Zum Host
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{host && (
|
||||
<section className="mt-3 flex flex-col gap-3" aria-label="Verwendungen in dieser Zelle">
|
||||
{eintraege.length === 0 ? (
|
||||
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-4 text-center text-[14px]">
|
||||
Auf diesem Host ist der Schlüssel nicht zugewiesen.
|
||||
</p>
|
||||
) : (
|
||||
eintraege.map((e) => (
|
||||
<InnereBox
|
||||
key={`${e.art}-${e.verwendungId}`}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<p className="text-[14px] font-medium">
|
||||
{KENNUNG_TEXT[e.kennung]}
|
||||
{e.gruppe && <> „{e.gruppe.name}“</>}
|
||||
</p>
|
||||
<p className="text-muted text-[13px]">
|
||||
Zielbenutzer <span className="text-text font-mono">{e.zielbenutzer}</span>
|
||||
</p>
|
||||
{e.art === "public" ? (
|
||||
<p className="text-muted text-[13px] break-all">
|
||||
Optionen <span className="text-text font-mono text-[12.5px]">{e.optionen ?? "–"}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted text-[13px] break-all">
|
||||
Pfad <span className="text-text font-mono text-[12.5px]">{e.pfad}</span>
|
||||
</p>
|
||||
)}
|
||||
<VerwendungsZustand zustand={e.zustand} />
|
||||
{e.gruppe && darfZuweisen && (
|
||||
<p className="text-muted text-[12.5px]">
|
||||
Entfernen gilt für alle Hosts der Gruppe „{e.gruppe.name}“.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<EintragAktionen
|
||||
art={e.art}
|
||||
verwendungId={e.verwendungId}
|
||||
schluesselId={schluessel.id}
|
||||
darfEntfernen={
|
||||
e.zustand !== "ENTFERNEN" && (e.art === "public" ? darfZuweisen : darfPrivatEntfernen)
|
||||
}
|
||||
testbar={false}
|
||||
/>
|
||||
</InnereBox>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{darfZuweisen && auswahl && (
|
||||
<section className="border-border mt-5 flex flex-col gap-3 border-t pt-5">
|
||||
<h3 className="text-[15px] font-semibold">Public Key zuweisen</h3>
|
||||
<PublicZuweisenFormular
|
||||
key={formularNr}
|
||||
schluesselId={schluessel.id}
|
||||
auswahl={auswahl}
|
||||
{...(vorauswahl ? { vorauswahl } : {})}
|
||||
beiErfolg={() => {
|
||||
setFormularNr((n) => n + 1);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{darfZuweisen && (
|
||||
<div className="border-border mt-5 flex flex-col gap-2 border-t pt-4">
|
||||
<p className="text-muted text-[13px]">
|
||||
Zuweisen und Entfernen ändern auf dem Host noch nichts – erst der Dry-Run auf der Schlüsselseite
|
||||
zeigt und schreibt die Änderung.
|
||||
</p>
|
||||
<Button
|
||||
href={`/schluessel/${schluessel.id}#dry-run`}
|
||||
klein
|
||||
className="self-start"
|
||||
icon={<ArrowRight className="size-4" />}
|
||||
>
|
||||
Zum Dry-Run
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ZUSTAND_ANZEIGE } from "@/components/ausrollen/VerwendungsZustand";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import {
|
||||
KENNUNG_TEXT,
|
||||
ZUSTAND_KURZ,
|
||||
ZUSTAND_TEXT,
|
||||
zellenSchluessel,
|
||||
type MatrixHost,
|
||||
type MatrixSchluessel,
|
||||
type Zelle,
|
||||
} from "@/lib/zuweisungsmatrix";
|
||||
import type { ZielAuswahl } from "@/server/queries/ziele";
|
||||
import { ZellenDialog, type ZellenAuswahl } from "./ZellenDialog";
|
||||
|
||||
/**
|
||||
* Matrix Schluessel × Host (#025). Ab md eine Tabelle mit eigenem Scrollbereich – die Seite
|
||||
* selbst scrollt nie seitwaerts –, darunter eine Liste je Schluessel, weil auf 390 px keine
|
||||
* zweite Spalte neben den Schluesselnamen passt.
|
||||
*/
|
||||
export type MatrixProps = {
|
||||
zeilen: readonly MatrixSchluessel[];
|
||||
spalten: readonly MatrixHost[];
|
||||
/** Nur belegte Zellen, Schluessel `${schluesselId}:${hostId}`. */
|
||||
zellen: Readonly<Record<string, Zelle>>;
|
||||
darfZuweisen: boolean;
|
||||
darfPrivatEntfernen: boolean;
|
||||
auswahl?: ZielAuswahl;
|
||||
};
|
||||
|
||||
/** Langtext fuer title und Screenreader: Art, Gruppe, Benutzer und Zustand je Eintrag. */
|
||||
function zellenText(zelle: Zelle): string {
|
||||
return zelle.eintraege
|
||||
.map(
|
||||
(e) =>
|
||||
`${KENNUNG_TEXT[e.kennung]}${e.gruppe ? ` ${e.gruppe.name}` : ""}, Benutzer ${e.zielbenutzer}, ${ZUSTAND_TEXT[e.zustand]}`,
|
||||
)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function zielbenutzerKurz(zelle: Zelle): string {
|
||||
const [erster, ...rest] = zelle.zielbenutzer;
|
||||
return rest.length > 0 ? `${erster} +${rest.length}` : (erster ?? "");
|
||||
}
|
||||
|
||||
export function Zuweisungsmatrix({
|
||||
zeilen,
|
||||
spalten,
|
||||
zellen,
|
||||
darfZuweisen,
|
||||
darfPrivatEntfernen,
|
||||
auswahl,
|
||||
}: MatrixProps) {
|
||||
const [offen, setOffen] = useState<ZellenAuswahl>();
|
||||
const zuweisbar = (s: MatrixSchluessel) => darfZuweisen && !!auswahl && s.kategorie !== "DEPLOYMENT";
|
||||
|
||||
const schluessel = offen ? zeilen.find((s) => s.id === offen.schluesselId) : undefined;
|
||||
const host = offen?.hostId ? spalten.find((h) => h.id === offen.hostId) : undefined;
|
||||
const zelle = offen?.hostId ? zellen[zellenSchluessel(offen.schluesselId, offen.hostId)] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Kopfzeile und erste Spalte bleiben beim Scrollen stehen. Der Scrollbereich
|
||||
braucht eine eigene Hoehe, sonst klebt die Kopfzeile am Container statt am Fenster. */}
|
||||
<div className="border-border bg-surface relative hidden max-h-[calc(100dvh-11rem)] overflow-auto rounded-[12px] border md:block">
|
||||
<table className="min-w-full border-separate border-spacing-0 text-left text-[13px]">
|
||||
<caption className="sr-only">
|
||||
Zuweisungen: Schlüssel in den Zeilen, Hosts in den Spalten. Eine Zelle öffnet die Details.
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="bg-surface border-border-strong text-muted sticky top-0 left-0 z-30 min-w-56 border-r border-b px-3 py-2.5 text-[13px] font-medium"
|
||||
>
|
||||
Schlüssel
|
||||
</th>
|
||||
{spalten.map((h) => (
|
||||
<th
|
||||
key={h.id}
|
||||
scope="col"
|
||||
className="bg-surface border-border-strong sticky top-0 z-20 border-b px-1 py-2.5 align-bottom font-medium"
|
||||
>
|
||||
<Link
|
||||
href={`/hosts/${h.id}`}
|
||||
title={h.name}
|
||||
className="text-muted hover:text-accent block max-w-28 truncate px-1.5 font-mono text-[12.5px] transition-colors"
|
||||
>
|
||||
{h.name}
|
||||
</Link>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zeilen.map((s) => (
|
||||
<tr key={s.id} className="group">
|
||||
<th
|
||||
scope="row"
|
||||
className="bg-surface border-border group-hover:bg-raised sticky left-0 z-10 max-w-72 border-r border-b px-3 py-1.5 font-normal transition-colors"
|
||||
>
|
||||
<Link
|
||||
href={`/schluessel/${s.id}`}
|
||||
title={s.name}
|
||||
className="text-text hover:text-accent block truncate text-[13.5px] font-medium transition-colors"
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
{s.person && <span className="text-muted block truncate text-[12px]">{s.person.name}</span>}
|
||||
</th>
|
||||
{spalten.map((h) => {
|
||||
const z = zellen[zellenSchluessel(s.id, h.id)];
|
||||
return (
|
||||
<td key={h.id} className="border-border border-b p-0.5 align-middle">
|
||||
{z ? (
|
||||
<BelegteZelle
|
||||
zelle={z}
|
||||
schluessel={s}
|
||||
host={h}
|
||||
oeffnen={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||
/>
|
||||
) : zuweisbar(s) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||
title={`${s.name} auf ${h.name} zuweisen`}
|
||||
className="text-border-strong hover:text-accent hover:bg-raised rounded-feld flex min-h-11 w-full min-w-20 items-center justify-center transition-colors"
|
||||
>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="sr-only">
|
||||
{s.name} auf {h.name}: nicht zugewiesen – Details und Zuweisen
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-border-strong flex min-h-11 min-w-20 items-center justify-center">
|
||||
<span aria-hidden>·</span>
|
||||
<span className="sr-only">nicht zugewiesen</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Telefon: je Schluessel nur die Hosts, auf denen er verwaltet ist. */}
|
||||
<ul className="flex flex-col gap-3 md:hidden">
|
||||
{zeilen.map((s) => {
|
||||
const belegt = spalten.flatMap((h) => {
|
||||
const z = zellen[zellenSchluessel(s.id, h.id)];
|
||||
return z ? [{ host: h, zelle: z }] : [];
|
||||
});
|
||||
return (
|
||||
<li key={s.id} className="border-border bg-surface rounded-karte min-w-0 border p-4">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/schluessel/${s.id}`}
|
||||
className="text-text hover:text-accent block truncate text-[14.5px] font-medium"
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
<span className="text-muted block text-[12.5px]">
|
||||
{belegt.length === 1 ? "1 Host" : `${belegt.length} Hosts`}
|
||||
{s.person && <> · {s.person.name}</>}
|
||||
</span>
|
||||
</div>
|
||||
{zuweisbar(s) && (
|
||||
<Button
|
||||
klein
|
||||
icon={<Plus className="size-4" />}
|
||||
onClick={() => setOffen({ schluesselId: s.id, hostId: null })}
|
||||
>
|
||||
Zuweisen
|
||||
<span className="sr-only"> – {s.name}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{belegt.length > 0 && (
|
||||
<ul className="mt-3 flex flex-col">
|
||||
{belegt.map(({ host: h, zelle: z }) => {
|
||||
const anzeige = ZUSTAND_ANZEIGE[z.zustand];
|
||||
return (
|
||||
<li key={h.id} className="border-border border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOffen({ schluesselId: s.id, hostId: h.id })}
|
||||
className="hover:bg-raised/60 rounded-box -mx-2 flex min-h-11 w-[calc(100%+1rem)] min-w-0 items-center justify-between gap-3 px-2 py-2 text-left"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="text-text block truncate font-mono text-[13px]">{h.name}</span>
|
||||
<span className="text-muted block truncate text-[12.5px]">
|
||||
{z.kennungen.join(" ")} ·{" "}
|
||||
<span className="font-mono">{zielbenutzerKurz(z)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex shrink-0 items-center gap-1.5 text-[13px] whitespace-nowrap",
|
||||
anzeige.klasse,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{anzeige.punkt}</span>
|
||||
{ZUSTAND_TEXT[z.zustand]}
|
||||
</span>
|
||||
<span className="sr-only">: {zellenText(z)}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{offen && schluessel && (
|
||||
<ZellenDialog
|
||||
schluessel={schluessel}
|
||||
host={host}
|
||||
zelle={zelle}
|
||||
darfZuweisen={zuweisbar(schluessel)}
|
||||
darfPrivatEntfernen={darfPrivatEntfernen}
|
||||
{...(auswahl ? { auswahl } : {})}
|
||||
schliessen={() => setOffen(undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BelegteZelle({
|
||||
zelle,
|
||||
schluessel,
|
||||
host,
|
||||
oeffnen,
|
||||
}: {
|
||||
zelle: Zelle;
|
||||
schluessel: MatrixSchluessel;
|
||||
host: MatrixHost;
|
||||
oeffnen: () => void;
|
||||
}) {
|
||||
const anzeige = ZUSTAND_ANZEIGE[zelle.zustand];
|
||||
const text = zellenText(zelle);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={oeffnen}
|
||||
title={text}
|
||||
className="hover:bg-raised rounded-feld flex min-h-11 w-full min-w-20 flex-col items-start justify-center gap-0.5 px-2 py-1 text-left transition-colors"
|
||||
>
|
||||
<span aria-hidden className="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<span className={anzeige.klasse}>{anzeige.punkt}</span>
|
||||
<span className="text-text font-medium">{zelle.kennungen.join(" ")}</span>
|
||||
<span className={clsx("text-[11.5px]", anzeige.klasse)}>{ZUSTAND_KURZ[zelle.zustand]}</span>
|
||||
</span>
|
||||
<span aria-hidden className="text-muted block max-w-24 truncate font-mono text-[11.5px]">
|
||||
{zielbenutzerKurz(zelle)}
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{schluessel.name} auf {host.name}: {text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { z } from "@/lib/validierung";
|
||||
import { ANLAESSE, webhookUrlPruefen, type AnlassArt } from "./regeln";
|
||||
|
||||
/**
|
||||
* Form der Benachrichtigungs-Einstellungen (#028). Gespeichert als JSON in `Einstellung`
|
||||
* unter EINSTELLUNG_BENACHRICHTIGUNG; Passwort und Webhook-Geheimnis liegen dort nur
|
||||
* verschluesselt (`verschluesseln()` mit SSHKV_APP_SECRET).
|
||||
*/
|
||||
export const EINSTELLUNG_BENACHRICHTIGUNG = "benachrichtigungen";
|
||||
|
||||
/** STARTTLS ist der Weg von Microsoft 365 (Port 587); KEINE nur fuer interne Relays. */
|
||||
export const SMTP_SICHERHEIT = ["STARTTLS", "TLS", "KEINE"] as const;
|
||||
export type SmtpSicherheit = (typeof SMTP_SICHERHEIT)[number];
|
||||
|
||||
export type GespeicherteBenachrichtigung = {
|
||||
email: {
|
||||
aktiv: boolean;
|
||||
server: string;
|
||||
port: number;
|
||||
sicherheit: SmtpSicherheit;
|
||||
absender: string;
|
||||
benutzer: string;
|
||||
/** Chiffrat aus `verschluesseln()` oder null. */
|
||||
passwort: string | null;
|
||||
empfaenger: string[];
|
||||
};
|
||||
webhook: {
|
||||
aktiv: boolean;
|
||||
url: string;
|
||||
httpErlaubt: boolean;
|
||||
/** Chiffrat aus `verschluesseln()` oder null. */
|
||||
geheimnis: string | null;
|
||||
};
|
||||
anlaesse: Record<AnlassArt, boolean>;
|
||||
};
|
||||
|
||||
/** Vorbelegung fuer Microsoft 365 – alles aus, bis ein Admin es einschaltet. */
|
||||
export const STANDARD_BENACHRICHTIGUNG: GespeicherteBenachrichtigung = {
|
||||
email: {
|
||||
aktiv: false,
|
||||
server: "smtp.office365.com",
|
||||
port: 587,
|
||||
sicherheit: "STARTTLS",
|
||||
absender: "",
|
||||
benutzer: "",
|
||||
passwort: null,
|
||||
empfaenger: [],
|
||||
},
|
||||
webhook: { aktiv: false, url: "", httpErlaubt: false, geheimnis: null },
|
||||
anlaesse: { drift: true, ablauf: true, abgelaufen: true, unerreichbar: true, hostkey: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Was die Oberflaeche sehen darf: statt der Geheimnisse nur, ob sie gesetzt sind. Auch das
|
||||
* Chiffrat geht nicht in Client-Props – es waere mit SSHKV_APP_SECRET lesbar.
|
||||
*/
|
||||
export type BenachrichtigungAnsicht = {
|
||||
email: Omit<GespeicherteBenachrichtigung["email"], "passwort"> & { passwortGesetzt: boolean };
|
||||
webhook: Omit<GespeicherteBenachrichtigung["webhook"], "geheimnis"> & { geheimnisGesetzt: boolean };
|
||||
anlaesse: Record<AnlassArt, boolean>;
|
||||
};
|
||||
|
||||
export function alsAnsicht(gespeichert: GespeicherteBenachrichtigung): BenachrichtigungAnsicht {
|
||||
const { passwort, ...email } = gespeichert.email;
|
||||
const { geheimnis, ...webhook } = gespeichert.webhook;
|
||||
return {
|
||||
email: { ...email, passwortGesetzt: passwort !== null },
|
||||
webhook: { ...webhook, geheimnisGesetzt: geheimnis !== null },
|
||||
anlaesse: { ...gespeichert.anlaesse },
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest gespeichertes JSON tolerant: fehlende Felder bekommen die Vorbelegung. */
|
||||
export function gespeichertLesen(wert: unknown): GespeicherteBenachrichtigung {
|
||||
const roh = (
|
||||
typeof wert === "object" && wert !== null ? wert : {}
|
||||
) as Partial<GespeicherteBenachrichtigung>;
|
||||
const s = STANDARD_BENACHRICHTIGUNG;
|
||||
return {
|
||||
email: { ...s.email, ...(roh.email ?? {}) },
|
||||
webhook: { ...s.webhook, ...(roh.webhook ?? {}) },
|
||||
anlaesse: { ...s.anlaesse, ...(roh.anlaesse ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Formular
|
||||
|
||||
/** Empfaengerliste: Komma, Semikolon, Leerzeichen oder Zeilenumbruch trennen. */
|
||||
export function empfaengerAus(text: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
text
|
||||
.split(/[\s,;]+/)
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const email = z.email({ error: "Keine gültige E-Mail-Adresse." });
|
||||
|
||||
export const benachrichtigungSchema = z
|
||||
.object({
|
||||
emailAktiv: z.boolean(),
|
||||
emailServer: z.string().trim().max(253),
|
||||
emailPort: z.coerce.number().int().min(1, "Port 1–65535.").max(65535, "Port 1–65535."),
|
||||
emailSicherheit: z.enum(SMTP_SICHERHEIT),
|
||||
emailAbsender: z.string().trim().max(254),
|
||||
emailBenutzer: z.string().trim().max(254),
|
||||
/** Leer = unveraendert lassen. */
|
||||
emailPasswort: z.string().max(1024).optional(),
|
||||
emailPasswortEntfernen: z.boolean(),
|
||||
emailEmpfaenger: z.array(z.string()).max(50, "Höchstens 50 Empfänger."),
|
||||
webhookAktiv: z.boolean(),
|
||||
webhookUrl: z.string().trim().max(2048),
|
||||
webhookHttpErlaubt: z.boolean(),
|
||||
webhookGeheimnis: z.string().max(1024).optional(),
|
||||
webhookGeheimnisEntfernen: z.boolean(),
|
||||
anlaesse: z.array(z.enum(ANLAESSE)),
|
||||
})
|
||||
.superRefine((e, ctx) => {
|
||||
const fehler = (pfad: string, message: string) => ctx.addIssue({ code: "custom", path: [pfad], message });
|
||||
if (e.emailAktiv && !e.emailServer) fehler("emailServer", "Server angeben.");
|
||||
if (e.emailServer && !/^[A-Za-z0-9.-]+$/.test(e.emailServer))
|
||||
fehler("emailServer", "Nur Hostname oder IP.");
|
||||
if (e.emailAktiv && !e.emailAbsender) fehler("emailAbsender", "Absender angeben.");
|
||||
if (e.emailAbsender && !email.safeParse(e.emailAbsender).success)
|
||||
fehler("emailAbsender", "Keine gültige E-Mail-Adresse.");
|
||||
if (e.emailAktiv && e.emailEmpfaenger.length === 0)
|
||||
fehler("emailEmpfaenger", "Mindestens ein Empfänger.");
|
||||
const falsch = e.emailEmpfaenger.find((a) => !email.safeParse(a).success);
|
||||
if (falsch) fehler("emailEmpfaenger", `„${falsch.slice(0, 80)}“ ist keine gültige E-Mail-Adresse.`);
|
||||
if (e.webhookAktiv && !e.webhookUrl) fehler("webhookUrl", "Adresse angeben.");
|
||||
if (e.webhookUrl) {
|
||||
const meldung = webhookUrlPruefen(e.webhookUrl, e.webhookHttpErlaubt);
|
||||
if (meldung) fehler("webhookUrl", meldung);
|
||||
}
|
||||
});
|
||||
|
||||
export type BenachrichtigungEingabe = z.input<typeof benachrichtigungSchema>;
|
||||
export type BenachrichtigungDaten = z.output<typeof benachrichtigungSchema>;
|
||||
@@ -0,0 +1,346 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { datum, datumZeit } from "@/lib/format";
|
||||
import { PRODUKTNAME } from "@/lib/version";
|
||||
|
||||
/**
|
||||
* Nachrichtenaufbau der Benachrichtigungen (#028): Betreff, Text- und HTML-Mail auf Deutsch
|
||||
* und die JSON-Nutzlast des Webhooks – aus denselben Daten, damit beide Wege dasselbe sagen.
|
||||
*
|
||||
* Alles, was in HTML landet, stammt teils von den Hosts (Kommentare in authorized_keys,
|
||||
* Benutzernamen) und wird deshalb maskiert. Schlichtes HTML mit Inline-Stilen, weil
|
||||
* E-Mail-Programme Stylesheets ignorieren (Muster aus ProxLedger).
|
||||
*/
|
||||
|
||||
export const ART_TEXT: Readonly<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",
|
||||
};
|
||||
|
||||
export type Befund = {
|
||||
readonly art: string;
|
||||
readonly zielbenutzer: string | null;
|
||||
readonly fingerprint: string | null;
|
||||
readonly text: string | null;
|
||||
};
|
||||
|
||||
export type HostDrift = {
|
||||
readonly hostId: string;
|
||||
readonly name: string;
|
||||
readonly adresse: string;
|
||||
readonly befunde: readonly Befund[];
|
||||
};
|
||||
|
||||
export type HostUnerreichbar = {
|
||||
readonly hostId: string;
|
||||
readonly name: string;
|
||||
readonly adresse: string;
|
||||
readonly zuletztGeprueftAm: Date | null;
|
||||
readonly letzterAbgleichAm: Date | null;
|
||||
};
|
||||
|
||||
export type SchluesselAblauf = {
|
||||
readonly schluesselId: string;
|
||||
readonly name: string;
|
||||
readonly fingerprint: string;
|
||||
readonly ablaufAm: Date;
|
||||
readonly tage: number;
|
||||
readonly stufe: number;
|
||||
};
|
||||
|
||||
export type SchluesselAbgelaufen = {
|
||||
readonly schluesselId: string;
|
||||
readonly name: string;
|
||||
readonly fingerprint: string;
|
||||
readonly ablaufAm: Date;
|
||||
readonly hosts: readonly string[];
|
||||
};
|
||||
|
||||
export type Zusammenfassung = {
|
||||
readonly drift: readonly HostDrift[];
|
||||
readonly unerreichbar: readonly HostUnerreichbar[];
|
||||
readonly ablauf: readonly SchluesselAblauf[];
|
||||
readonly abgelaufen: readonly SchluesselAbgelaufen[];
|
||||
};
|
||||
|
||||
export type HostKeyAenderung = {
|
||||
readonly hostId: string;
|
||||
readonly name: string;
|
||||
readonly adresse: string;
|
||||
readonly befunde: readonly { algorithmus: string; erwartet: string | null; gesehen: string }[];
|
||||
};
|
||||
|
||||
export type WebhookNutzlast = {
|
||||
readonly ereignis: "zusammenfassung" | "hostkey.geaendert" | "test";
|
||||
readonly zeitstempel: string;
|
||||
readonly instanz: string;
|
||||
readonly [weiteres: string]: unknown;
|
||||
};
|
||||
|
||||
export type Nachricht = {
|
||||
readonly betreff: string;
|
||||
readonly text: string;
|
||||
readonly html: string;
|
||||
readonly nutzlast: WebhookNutzlast;
|
||||
};
|
||||
|
||||
export type Rahmen = { readonly appUrl: string; readonly jetzt: Date };
|
||||
|
||||
// ---------------------------------------------------------------- Hilfen
|
||||
|
||||
export function html(text: string): string {
|
||||
return text
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function basis(appUrl: string): string {
|
||||
return appUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function mehrzahl(anzahl: number, eins: string, viele: string): string {
|
||||
return `${anzahl} ${anzahl === 1 ? eins : viele}`;
|
||||
}
|
||||
|
||||
function htmlRahmen(inhalt: string, appUrl: string, linkText: string, pfad: string): string {
|
||||
return `<!doctype html><html lang="de"><body style="margin:0;padding:24px;background:#ffffff">
|
||||
<div style="font-family:'IBM Plex Sans',Arial,sans-serif;font-size:15px;line-height:1.5;color:#1d232b;max-width:680px">
|
||||
<div style="border-bottom:3px solid #b08a3e;padding-bottom:8px;margin-bottom:16px">
|
||||
<span style="font-size:20px;font-weight:600">${PRODUKTNAME}</span>
|
||||
<span style="color:#5d6672"> · Benachrichtigung</span>
|
||||
</div>
|
||||
${inhalt}
|
||||
<p style="color:#5d6672;font-size:13px;margin-top:24px">
|
||||
<a href="${html(basis(appUrl) + pfad)}" style="color:#8a6a2a">${html(linkText)}</a>
|
||||
</p>
|
||||
</div></body></html>`;
|
||||
}
|
||||
|
||||
function befundZeile(b: Befund): string {
|
||||
const teile = [ART_TEXT[b.art] ?? b.art];
|
||||
if (b.zielbenutzer) teile.push(`Benutzer ${b.zielbenutzer}`);
|
||||
if (b.fingerprint) teile.push(b.fingerprint);
|
||||
if (b.text) teile.push(b.text);
|
||||
return teile.join(" – ");
|
||||
}
|
||||
|
||||
function befundJson(b: Befund) {
|
||||
return {
|
||||
art: b.art,
|
||||
artText: ART_TEXT[b.art] ?? b.art,
|
||||
zielbenutzer: b.zielbenutzer,
|
||||
fingerprint: b.fingerprint,
|
||||
text: b.text,
|
||||
};
|
||||
}
|
||||
|
||||
const H3 = 'style="font-size:16px;margin:20px 0 6px"';
|
||||
const UL = 'style="margin:4px 0 12px;padding-left:20px"';
|
||||
const MONO = "font-family:'JetBrains Mono',Consolas,monospace;font-size:13px";
|
||||
|
||||
// ---------------------------------------------------------------- Zusammenfassung
|
||||
|
||||
export function zusammenfassungLeer(z: Zusammenfassung): boolean {
|
||||
return z.drift.length + z.unerreichbar.length + z.ablauf.length + z.abgelaufen.length === 0;
|
||||
}
|
||||
|
||||
/** Tägliche Zusammenfassung der Anlaesse (a)–(d). */
|
||||
export function zusammenfassungNachricht(z: Zusammenfassung, rahmen: Rahmen): Nachricht {
|
||||
const befundAnzahl = z.drift.reduce((summe, h) => summe + h.befunde.length, 0);
|
||||
const kopf: string[] = [];
|
||||
if (befundAnzahl > 0) kopf.push(mehrzahl(befundAnzahl, "neue Abweichung", "neue Abweichungen"));
|
||||
if (z.unerreichbar.length > 0)
|
||||
kopf.push(mehrzahl(z.unerreichbar.length, "Host nicht erreichbar", "Hosts nicht erreichbar"));
|
||||
if (z.ablauf.length > 0) kopf.push(mehrzahl(z.ablauf.length, "Schlüssel läuft ab", "Schlüssel laufen ab"));
|
||||
if (z.abgelaufen.length > 0)
|
||||
kopf.push(
|
||||
mehrzahl(z.abgelaufen.length, "abgelaufener Schlüssel ausgerollt", "abgelaufene Schlüssel ausgerollt"),
|
||||
);
|
||||
const betreff = `[sshkv] Zusammenfassung: ${kopf.join(", ") || "keine neuen Meldungen"}`;
|
||||
const url = basis(rahmen.appUrl);
|
||||
|
||||
const text: string[] = [`${PRODUKTNAME} – Zusammenfassung vom ${datumZeit(rahmen.jetzt)}`, ""];
|
||||
const teile: string[] = [
|
||||
`<h2 style="font-size:18px;margin:0 0 8px">Zusammenfassung vom ${html(datumZeit(rahmen.jetzt))}</h2>`,
|
||||
];
|
||||
|
||||
if (z.drift.length > 0) {
|
||||
text.push("Neue Abweichungen (Drift):");
|
||||
teile.push(`<h3 ${H3}>Neue Abweichungen (Drift)</h3>`);
|
||||
for (const host of z.drift) {
|
||||
text.push(` ${host.name} (${host.adresse}):`);
|
||||
for (const b of host.befunde) text.push(` · ${befundZeile(b)}`);
|
||||
text.push(` ${url}/hosts/${host.hostId}`);
|
||||
teile.push(
|
||||
`<p style="margin:8px 0 2px"><a href="${html(`${url}/hosts/${host.hostId}`)}" style="color:#1d232b;font-weight:600;${MONO}">${html(host.name)}</a> <span style="color:#5d6672">${html(host.adresse)}</span></p>`,
|
||||
`<ul ${UL}>${host.befunde.map((b) => `<li>${html(befundZeile(b))}</li>`).join("")}</ul>`,
|
||||
);
|
||||
}
|
||||
text.push("");
|
||||
}
|
||||
|
||||
if (z.unerreichbar.length > 0) {
|
||||
text.push("Hosts nicht erreichbar:");
|
||||
teile.push(`<h3 ${H3}>Hosts nicht erreichbar</h3><ul ${UL}>`);
|
||||
for (const host of z.unerreichbar) {
|
||||
const zuletzt = host.letzterAbgleichAm
|
||||
? `letzter erfolgreicher Abgleich ${datumZeit(host.letzterAbgleichAm)}`
|
||||
: "noch nie erfolgreich abgeglichen";
|
||||
text.push(` · ${host.name} (${host.adresse}) – ${zuletzt}`);
|
||||
teile.push(
|
||||
`<li><span style="${MONO}">${html(host.name)}</span> (${html(host.adresse)}) – ${html(zuletzt)}</li>`,
|
||||
);
|
||||
}
|
||||
teile.push("</ul>");
|
||||
text.push("");
|
||||
}
|
||||
|
||||
if (z.ablauf.length > 0) {
|
||||
text.push("Bald ablaufende Schlüssel:");
|
||||
teile.push(`<h3 ${H3}>Bald ablaufende Schlüssel</h3><ul ${UL}>`);
|
||||
for (const s of z.ablauf) {
|
||||
const wann = `läuft am ${datum(s.ablaufAm)} ab (${s.tage === 1 ? "in 1 Tag" : `in ${s.tage} Tagen`})`;
|
||||
text.push(` · ${s.name} – ${wann}`, ` ${s.fingerprint}`);
|
||||
teile.push(
|
||||
`<li>${html(s.name)} – ${html(wann)}<br><span style="color:#5d6672;${MONO}">${html(s.fingerprint)}</span></li>`,
|
||||
);
|
||||
}
|
||||
teile.push("</ul>");
|
||||
text.push("");
|
||||
}
|
||||
|
||||
if (z.abgelaufen.length > 0) {
|
||||
text.push("Abgelaufen, aber noch ausgerollt:");
|
||||
teile.push(`<h3 ${H3}>Abgelaufen, aber noch ausgerollt</h3><ul ${UL}>`);
|
||||
for (const s of z.abgelaufen) {
|
||||
const wo = s.hosts.length > 0 ? `noch auf ${s.hosts.join(", ")}` : "noch ausgerollt";
|
||||
text.push(` · ${s.name} – abgelaufen am ${datum(s.ablaufAm)}, ${wo}`, ` ${s.fingerprint}`);
|
||||
teile.push(
|
||||
`<li>${html(s.name)} – abgelaufen am ${html(datum(s.ablaufAm))}, ${html(wo)}<br><span style="color:#5d6672;${MONO}">${html(s.fingerprint)}</span></li>`,
|
||||
);
|
||||
}
|
||||
teile.push("</ul>");
|
||||
text.push("");
|
||||
}
|
||||
|
||||
text.push(`Übersicht: ${url}/`);
|
||||
|
||||
return {
|
||||
betreff,
|
||||
text: text.join("\n"),
|
||||
html: htmlRahmen(teile.join("\n"), rahmen.appUrl, "Übersicht öffnen", "/"),
|
||||
nutzlast: {
|
||||
ereignis: "zusammenfassung",
|
||||
zeitstempel: rahmen.jetzt.toISOString(),
|
||||
instanz: url,
|
||||
drift: z.drift.map((h) => ({
|
||||
host: { id: h.hostId, name: h.name, adresse: h.adresse },
|
||||
befunde: h.befunde.map(befundJson),
|
||||
})),
|
||||
unerreichbar: z.unerreichbar.map((h) => ({
|
||||
host: { id: h.hostId, name: h.name, adresse: h.adresse },
|
||||
zuletztGeprueftAm: h.zuletztGeprueftAm?.toISOString() ?? null,
|
||||
letzterAbgleichAm: h.letzterAbgleichAm?.toISOString() ?? null,
|
||||
})),
|
||||
ablauf: z.ablauf.map((s) => ({
|
||||
schluessel: { id: s.schluesselId, name: s.name, fingerprint: s.fingerprint },
|
||||
ablaufAm: s.ablaufAm.toISOString(),
|
||||
tage: s.tage,
|
||||
stufe: s.stufe,
|
||||
})),
|
||||
abgelaufen: z.abgelaufen.map((s) => ({
|
||||
schluessel: { id: s.schluesselId, name: s.name, fingerprint: s.fingerprint },
|
||||
ablaufAm: s.ablaufAm.toISOString(),
|
||||
hosts: [...s.hosts],
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Host-Key (sofort)
|
||||
|
||||
/** Dringende Einzelmeldung (e) je Host. */
|
||||
export function hostKeyNachricht(h: HostKeyAenderung, rahmen: Rahmen): Nachricht {
|
||||
const url = `${basis(rahmen.appUrl)}/hosts/${h.hostId}`;
|
||||
const betreff = `[sshkv] DRINGEND: Host-Key von ${h.name} hat sich geändert`;
|
||||
const zeilen = h.befunde.map(
|
||||
(b) => `${b.algorithmus}: erwartet ${b.erwartet ?? "(unbekannt)"}, gesehen ${b.gesehen}`,
|
||||
);
|
||||
const text = [
|
||||
`Der Host-Key von ${h.name} (${h.adresse}) hat sich geändert.`,
|
||||
"",
|
||||
...zeilen.map((z) => ` · ${z}`),
|
||||
"",
|
||||
"sshkv verbindet sich mit diesem Host nicht mehr, bis jemand den neuen Key geprüft und",
|
||||
"bestätigt hat. Ist die Änderung nicht erklärbar (Neuinstallation, Austausch), kann ein",
|
||||
"Angreifer zwischen sshkv und dem Host sitzen.",
|
||||
"",
|
||||
url,
|
||||
].join("\n");
|
||||
const inhalt = `<p style="margin:0 0 8px"><span style="display:inline-block;padding:2px 10px;border-radius:999px;background:#b3261e;color:#ffffff;font-size:13px;font-weight:600">Dringend</span></p>
|
||||
<h2 style="font-size:18px;margin:8px 0">Host-Key von <span style="${MONO};font-size:17px">${html(h.name)}</span> geändert</h2>
|
||||
<p style="margin:0 0 8px">${html(h.adresse)}</p>
|
||||
<ul ${UL}>${zeilen.map((z) => `<li style="${MONO}">${html(z)}</li>`).join("")}</ul>
|
||||
<p style="margin:0">sshkv verbindet sich mit diesem Host nicht mehr, bis jemand den neuen Key geprüft und bestätigt hat. Ist die Änderung nicht erklärbar (Neuinstallation, Austausch), kann ein Angreifer zwischen sshkv und dem Host sitzen.</p>`;
|
||||
return {
|
||||
betreff,
|
||||
text,
|
||||
html: htmlRahmen(inhalt, rahmen.appUrl, "Host öffnen und Key prüfen", `/hosts/${h.hostId}`),
|
||||
nutzlast: {
|
||||
ereignis: "hostkey.geaendert",
|
||||
dringend: true,
|
||||
zeitstempel: rahmen.jetzt.toISOString(),
|
||||
instanz: basis(rahmen.appUrl),
|
||||
host: { id: h.hostId, name: h.name, adresse: h.adresse },
|
||||
befunde: h.befunde.map((b) => ({
|
||||
art: "HOSTKEY_GEAENDERT",
|
||||
artText: ART_TEXT["HOSTKEY_GEAENDERT"],
|
||||
algorithmus: b.algorithmus,
|
||||
erwartet: b.erwartet,
|
||||
fingerprint: b.gesehen,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Test
|
||||
|
||||
export function testNachricht(kanal: "EMAIL" | "WEBHOOK", rahmen: Rahmen): Nachricht {
|
||||
const weg = kanal === "EMAIL" ? "E-Mail" : "Webhook";
|
||||
const satz = `Diese Testnachricht bestätigt, dass der Versand per ${weg} funktioniert.`;
|
||||
return {
|
||||
betreff: "[sshkv] Testnachricht",
|
||||
text: `${satz}\nGesendet am ${datumZeit(rahmen.jetzt)}.\n\n${basis(rahmen.appUrl)}/einstellungen`,
|
||||
html: htmlRahmen(
|
||||
`<h2 style="font-size:18px;margin:0 0 8px">Testnachricht</h2><p style="margin:0">${html(satz)}</p><p style="color:#5d6672;margin:8px 0 0">Gesendet am ${html(datumZeit(rahmen.jetzt))}.</p>`,
|
||||
rahmen.appUrl,
|
||||
"Einstellungen öffnen",
|
||||
"/einstellungen",
|
||||
),
|
||||
nutzlast: {
|
||||
ereignis: "test",
|
||||
zeitstempel: rahmen.jetzt.toISOString(),
|
||||
instanz: basis(rahmen.appUrl),
|
||||
text: satz,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Webhook
|
||||
|
||||
/** Der Body wird genau einmal serialisiert – signiert wird, was auch gesendet wird. */
|
||||
export function webhookKoerper(nachricht: Nachricht): string {
|
||||
return JSON.stringify({ betreff: nachricht.betreff, ...nachricht.nutzlast });
|
||||
}
|
||||
|
||||
/** `X-Sshkv-Signatur: sha256=<hex>` – HMAC-SHA256 ueber den rohen Body. */
|
||||
export function signatur(koerper: string, geheimnis: string): string {
|
||||
return `sha256=${createHmac("sha256", geheimnis).update(koerper, "utf8").digest("hex")}`;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
Befund,
|
||||
HostKeyAenderung,
|
||||
HostUnerreichbar,
|
||||
SchluesselAbgelaufen,
|
||||
SchluesselAblauf,
|
||||
Zusammenfassung,
|
||||
} from "./nachrichten";
|
||||
|
||||
/**
|
||||
* Ein einzelner meldbarer Anlass mit seinem Fingerabdruck (#028). Der Worker sammelt Posten
|
||||
* aus der Datenbank, filtert sie je Kanal gegen das Versandprotokoll und baut aus dem Rest
|
||||
* eine Nachricht – so kann ein Kanal nachholen, was nur bei ihm fehlgeschlagen ist.
|
||||
*/
|
||||
type HostKurz = { readonly hostId: string; readonly name: string; readonly adresse: string };
|
||||
|
||||
export type Posten =
|
||||
| { readonly art: "drift"; readonly anlass: string; readonly host: HostKurz; readonly befund: Befund }
|
||||
| { readonly art: "unerreichbar"; readonly anlass: string; readonly host: HostUnerreichbar }
|
||||
| { readonly art: "ablauf"; readonly anlass: string; readonly schluessel: SchluesselAblauf }
|
||||
| { readonly art: "abgelaufen"; readonly anlass: string; readonly schluessel: SchluesselAbgelaufen }
|
||||
| {
|
||||
readonly art: "hostkey";
|
||||
readonly anlass: string;
|
||||
readonly host: HostKurz;
|
||||
readonly befund: HostKeyAenderung["befunde"][number];
|
||||
};
|
||||
|
||||
/** Fasst die Posten (a)–(d) zusammen; Drift je Host gruppiert, Reihenfolge nach Hostname. */
|
||||
export function zusammenfassungAus(posten: readonly Posten[]): Zusammenfassung {
|
||||
const drift = new Map<string, { host: HostKurz; befunde: Befund[] }>();
|
||||
const unerreichbar: HostUnerreichbar[] = [];
|
||||
const ablauf: SchluesselAblauf[] = [];
|
||||
const abgelaufen: SchluesselAbgelaufen[] = [];
|
||||
for (const p of posten) {
|
||||
if (p.art === "drift") {
|
||||
const eintrag = drift.get(p.host.hostId) ?? { host: p.host, befunde: [] };
|
||||
eintrag.befunde.push(p.befund);
|
||||
drift.set(p.host.hostId, eintrag);
|
||||
} else if (p.art === "unerreichbar") unerreichbar.push(p.host);
|
||||
else if (p.art === "ablauf") ablauf.push(p.schluessel);
|
||||
else if (p.art === "abgelaufen") abgelaufen.push(p.schluessel);
|
||||
}
|
||||
const nachName = (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name, "de");
|
||||
return {
|
||||
drift: [...drift.values()].map((d) => ({ ...d.host, befunde: d.befunde })).sort(nachName),
|
||||
unerreichbar: unerreichbar.sort(nachName),
|
||||
ablauf: ablauf.sort((a, b) => a.ablaufAm.getTime() - b.ablaufAm.getTime()),
|
||||
abgelaufen: abgelaufen.sort(nachName),
|
||||
};
|
||||
}
|
||||
|
||||
/** Host-Key-Posten (e) je Host – jeder Host bekommt seine eigene dringende Nachricht. */
|
||||
export function hostKeyGruppen(
|
||||
posten: readonly Posten[],
|
||||
): { aenderung: HostKeyAenderung; anlaesse: string[] }[] {
|
||||
const gruppen = new Map<
|
||||
string,
|
||||
{ aenderung: HostKeyAenderung & { befunde: HostKeyAenderung["befunde"][number][] }; anlaesse: string[] }
|
||||
>();
|
||||
for (const p of posten) {
|
||||
if (p.art !== "hostkey") continue;
|
||||
const gruppe = gruppen.get(p.host.hostId) ?? { aenderung: { ...p.host, befunde: [] }, anlaesse: [] };
|
||||
gruppe.aenderung.befunde.push(p.befund);
|
||||
gruppe.anlaesse.push(p.anlass);
|
||||
gruppen.set(p.host.hostId, gruppe);
|
||||
}
|
||||
return [...gruppen.values()];
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { tageBis } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* Regeln der Benachrichtigungen (#028), rein und ohne Datenbank – damit Stufen,
|
||||
* Fingerabdruecke und Wiederholungen als Unit-Test festgehalten werden koennen.
|
||||
*/
|
||||
|
||||
/** Die fuenf Anlaesse; je Anlass in den Einstellungen ein- und ausschaltbar. */
|
||||
export const ANLAESSE = ["drift", "ablauf", "abgelaufen", "unerreichbar", "hostkey"] as const;
|
||||
export type AnlassArt = (typeof ANLAESSE)[number];
|
||||
|
||||
export const ANLASS_TEXT: Readonly<Record<AnlassArt, { titel: string; erklaerung: string }>> = {
|
||||
drift: {
|
||||
titel: "Neue Abweichungen (Drift)",
|
||||
erklaerung: "Nach dem Abgleich neu gefundene Befunde, je Host zusammengefasst.",
|
||||
},
|
||||
ablauf: {
|
||||
titel: "Ablaufende Schlüssel",
|
||||
erklaerung: "30, 14 und 7 Tage vor dem Ablauf – je Stufe genau eine Meldung.",
|
||||
},
|
||||
abgelaufen: {
|
||||
titel: "Abgelaufene, noch ausgerollte Schlüssel",
|
||||
erklaerung: "Abgelaufen, aber noch auf mindestens einem Host hinterlegt.",
|
||||
},
|
||||
unerreichbar: {
|
||||
titel: "Host nicht erreichbar",
|
||||
erklaerung: "Einmal je Ausfall, bis wieder ein Abgleich gelingt.",
|
||||
},
|
||||
hostkey: {
|
||||
titel: "Geänderter Host-Key",
|
||||
erklaerung: "Dringend: wird sofort gemeldet, nicht erst in der Zusammenfassung.",
|
||||
},
|
||||
};
|
||||
|
||||
/** Ablaufstufen in Tagen, absteigend. */
|
||||
export const ABLAUF_STUFEN = [30, 14, 7] as const;
|
||||
export type AblaufStufe = (typeof ABLAUF_STUFEN)[number];
|
||||
|
||||
/**
|
||||
* Die Stufe, in der ein Schluessel gerade steht: die kleinste Stufe, deren Frist schon
|
||||
* erreicht ist. Wer erst mit 10 Tagen Restlaufzeit angelegt wird, bekommt also die
|
||||
* 14-Tage-Meldung und spaeter die 7-Tage-Meldung, aber nie nachtraeglich die 30-Tage-Meldung.
|
||||
* Gezaehlt wird in Kalendertagen (Europe/Berlin) wie ueberall in der Oberflaeche.
|
||||
*/
|
||||
export function ablaufStufe(ablaufAm: Date, jetzt: Date = new Date()): AblaufStufe | null {
|
||||
if (ablaufAm.getTime() <= jetzt.getTime()) return null;
|
||||
const tage = tageBis(ablaufAm, jetzt);
|
||||
for (const stufe of [...ABLAUF_STUFEN].reverse()) {
|
||||
if (tage <= stufe) return stufe;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tag(wert: Date): string {
|
||||
return wert.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerabdruecke der Anlaesse – Schluessel des Versandprotokolls. Was hier gleich ist, wird
|
||||
* nur einmal gemeldet. Beim Ablauf steht das Ablaufdatum mit drin: wird ein Schluessel
|
||||
* verlaengert, beginnen die Stufen fuer das neue Datum von vorn.
|
||||
*/
|
||||
export const fingerabdruck = {
|
||||
drift: (abweichungId: string) => `drift:${abweichungId}`,
|
||||
hostkey: (abweichungId: string) => `hostkey:${abweichungId}`,
|
||||
ablauf: (schluesselId: string, stufe: AblaufStufe, ablaufAm: Date) =>
|
||||
`ablauf:${schluesselId}:${stufe}:${tag(ablaufAm)}`,
|
||||
abgelaufen: (schluesselId: string, ablaufAm: Date) => `abgelaufen:${schluesselId}:${tag(ablaufAm)}`,
|
||||
// `letzterAbgleichAm` aendert sich nur bei einem gelungenen Abgleich – so entsteht genau eine
|
||||
// Meldung je Ausfall, und nach der Erholung kann der naechste Ausfall wieder gemeldet werden.
|
||||
unerreichbar: (hostId: string, letzterAbgleichAm: Date | null) =>
|
||||
`unerreichbar:${hostId}:${letzterAbgleichAm ? letzterAbgleichAm.toISOString() : "nie"}`,
|
||||
test: (sendung: string) => `test:${sendung}`,
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------- Wiederholungen
|
||||
|
||||
/** Nach so vielen Fehlversuchen gibt sshkv eine Zustellung auf. */
|
||||
export const MAX_VERSUCHE = 5;
|
||||
|
||||
/** 5, 10, 20, 40 Minuten – kurz genug fuer einen kurzen Ausfall des Mailservers. */
|
||||
export function naechsterVersuch(versuche: number, jetzt: Date = new Date()): Date | null {
|
||||
if (versuche >= MAX_VERSUCHE) return null;
|
||||
return new Date(jetzt.getTime() + 5 * 60_000 * 2 ** (versuche - 1));
|
||||
}
|
||||
|
||||
export type ProtokollStand = {
|
||||
readonly ergebnis: "GESENDET" | "FEHLGESCHLAGEN";
|
||||
readonly versuche: number;
|
||||
readonly naechsterVersuchAm: Date | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Darf ein Anlass ueber einen Kanal (erneut) gemeldet werden?
|
||||
* - noch nie versucht: ja, ausser es laeuft nur eine Wiederholungsrunde;
|
||||
* - einmal erfolgreich: nie wieder;
|
||||
* - fehlgeschlagen: wenn die Wartezeit um ist und noch Versuche uebrig sind.
|
||||
*/
|
||||
export function anlassFaellig(
|
||||
stand: ProtokollStand | undefined,
|
||||
jetzt: Date = new Date(),
|
||||
nurWiederholungen = false,
|
||||
): boolean {
|
||||
if (!stand) return !nurWiederholungen;
|
||||
if (stand.ergebnis === "GESENDET") return false;
|
||||
if (stand.versuche >= MAX_VERSUCHE || !stand.naechsterVersuchAm) return false;
|
||||
return stand.naechsterVersuchAm.getTime() <= jetzt.getTime();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Webhook-Ziel
|
||||
|
||||
/**
|
||||
* Nur https, ausser `http` ist ausdruecklich erlaubt (internes Ziel). Liefert eine Meldung
|
||||
* fuer das Formular oder null.
|
||||
*/
|
||||
export function webhookUrlPruefen(url: string, httpErlaubt: boolean): string | null {
|
||||
let ziel: URL;
|
||||
try {
|
||||
ziel = new URL(url);
|
||||
} catch {
|
||||
return "Keine gültige Adresse.";
|
||||
}
|
||||
if (ziel.username || ziel.password) {
|
||||
return "Zugangsdaten gehören nicht in die Adresse – bitte das Geheimnis-Feld nutzen.";
|
||||
}
|
||||
if (ziel.protocol === "https:") return null;
|
||||
if (ziel.protocol === "http:") {
|
||||
return httpErlaubt ? null : "Nur https:// – http:// nur mit dem Häkchen für interne Ziele.";
|
||||
}
|
||||
return "Nur https:// (oder ausdrücklich erlaubtes http://).";
|
||||
}
|
||||
|
||||
/** Fuer Protokoll und Versandliste: nur Schema und Host, nie Pfad oder Query (Tokens). */
|
||||
export function webhookZielKurz(url: string): string {
|
||||
try {
|
||||
const ziel = new URL(url);
|
||||
return `${ziel.protocol}//${ziel.host}`;
|
||||
} catch {
|
||||
return "(ungültige Adresse)";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zeitplaene
|
||||
|
||||
/**
|
||||
* Lesbare Fassung der gaengigen cron-Formen fuer die Einstellungsseite; bei allem anderen
|
||||
* null – dann zeigt die Seite nur den Ausdruck.
|
||||
*/
|
||||
export function cronBeschreiben(ausdruck: string): string | null {
|
||||
const teile = ausdruck.trim().split(/\s+/);
|
||||
if (teile.length !== 5) return null;
|
||||
const [minute, stunde, tagImMonat, monat, wochentag] = teile as [string, string, string, string, string];
|
||||
const zahl = (wert: string, max: number) => /^\d{1,2}$/.test(wert) && Number(wert) <= max;
|
||||
const intervall = /^\*\/(\d{1,2})$/.exec(minute);
|
||||
if (intervall && stunde === "*" && tagImMonat === "*" && monat === "*" && wochentag === "*") {
|
||||
return `alle ${Number(intervall[1])} Minuten`;
|
||||
}
|
||||
if (!zahl(minute, 59) || !zahl(stunde, 23) || tagImMonat !== "*" || monat !== "*") return null;
|
||||
const uhr = `${stunde.padStart(2, "0")}:${minute.padStart(2, "0")} Uhr`;
|
||||
if (wochentag === "*") return `täglich um ${uhr}`;
|
||||
if (wochentag === "1-5") return `werktags um ${uhr}`;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* CSV fuer Excel (#025). Framework-frei, damit spaetere Exporte (Protokoll, Phase 5) dieselben
|
||||
* Regeln nutzen.
|
||||
*
|
||||
* - Semikolon als Trenner und UTF-8-BOM: ein deutsches Excel oeffnet die Datei sonst als eine
|
||||
* Spalte mit kaputten Umlauten.
|
||||
* - Jeder Wert in Anfuehrungszeichen, damit Kommas in `authorized_keys`-Optionen und
|
||||
* Zeilenumbrueche nichts verschieben.
|
||||
* - Formel-Injektion: Excel und LibreOffice werten Zellen, die mit `= + - @` (oder Tab/CR)
|
||||
* beginnen, als Formel aus – ein Schluesselname wie `=HYPERLINK(…)` wuerde beim Oeffnen
|
||||
* ausgefuehrt. Ein vorangestelltes `'` macht daraus Text (OWASP „CSV Injection“).
|
||||
*/
|
||||
export const CSV_BOM = "";
|
||||
export const CSV_TRENNER = ";";
|
||||
|
||||
const FORMELBEGINN = /^[=+\-@\t\r]/;
|
||||
|
||||
export function csvEntschaerfen(wert: string): string {
|
||||
return FORMELBEGINN.test(wert) ? `'${wert}` : wert;
|
||||
}
|
||||
|
||||
export function csvWert(wert: string | number | null | undefined): string {
|
||||
const text = csvEntschaerfen(wert == null ? "" : String(wert));
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export function csvZeile(werte: readonly (string | number | null | undefined)[]): string {
|
||||
return werte.map(csvWert).join(CSV_TRENNER);
|
||||
}
|
||||
|
||||
/** Ganze Datei: BOM, Kopfzeile, Datenzeilen, CRLF wie Excel selbst. */
|
||||
export function csvDatei(
|
||||
kopf: readonly string[],
|
||||
zeilen: readonly (readonly (string | number | null | undefined)[])[],
|
||||
): string {
|
||||
return CSV_BOM + [kopf, ...zeilen].map(csvZeile).join("\r\n") + "\r\n";
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Lesbare Beschriftung der technischen Protokoll-Aktionen (#027).
|
||||
*
|
||||
* Das Protokoll speichert stabile Schluessel wie `authorized_keys.geschrieben`, damit sich
|
||||
* Auswertungen (CSV, Syslog, SIEM) nicht an Oberflaechentexten aufhaengen. Die Ansicht
|
||||
* uebersetzt sie hier. `tests/protokoll-aktionen.test.ts` sucht jede `aktion: "…"` im
|
||||
* Quellcode und verlangt einen Eintrag – eine neue Aktion ohne Text faellt so sofort auf.
|
||||
*
|
||||
* `aendert`: Die Aktion veraendert etwas (Datei geschrieben, Schluessel erzeugt …) und wird bei
|
||||
* Erfolg in --ok gezeigt. Lesende oder rein informative Aktionen (Anmeldung, Abruf, Export)
|
||||
* bleiben neutral, damit das Grün die Stellen markiert, an denen sich etwas getan hat.
|
||||
*/
|
||||
|
||||
export const AKTIONSGRUPPEN = {
|
||||
anmeldung: "Anmeldung",
|
||||
hostkey: "Host-Key",
|
||||
ausrollen: "Ausrollen",
|
||||
schluessel: "Schlüssel",
|
||||
widerruf: "Widerruf/Rotation",
|
||||
zielkonto: "Zielkonto",
|
||||
abgleich: "Abgleich",
|
||||
hosts: "Hosts",
|
||||
stammdaten: "Personen und Vorlagen",
|
||||
protokoll: "Protokoll",
|
||||
benachrichtigung: "Benachrichtigung",
|
||||
} as const;
|
||||
|
||||
export type Aktionsgruppe = keyof typeof AKTIONSGRUPPEN;
|
||||
|
||||
type Beschreibung = { readonly text: string; readonly gruppe: Aktionsgruppe; readonly aendert: boolean };
|
||||
|
||||
const a = (text: string, gruppe: Aktionsgruppe, aendert = true): Beschreibung => ({ text, gruppe, aendert });
|
||||
|
||||
export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
|
||||
// Benachrichtigungen (#028)
|
||||
"benachrichtigung.gesendet": a("Benachrichtigung gesendet", "benachrichtigung", false),
|
||||
"benachrichtigung.fehlgeschlagen": a("Benachrichtigung fehlgeschlagen", "benachrichtigung", false),
|
||||
"einstellungen.benachrichtigungen.geaendert": a("Benachrichtigungen geändert", "benachrichtigung"),
|
||||
// Ereignisname im Webhook (#028); taucht als Text auf, wenn ein Host-Key-Alarm protokolliert wird.
|
||||
"hostkey.geaendert": a("Host-Key geändert (Alarm)", "hostkey", false),
|
||||
|
||||
// Anmeldung und Bestaetigung (#004)
|
||||
"anmeldung.oidc": a("Anmeldung (OIDC)", "anmeldung", false),
|
||||
"anmeldung.oidc.erstmals": a("Erste Anmeldung (OIDC)", "anmeldung"),
|
||||
"anmeldung.verweigert": a("Anmeldung verweigert", "anmeldung", false),
|
||||
"anmeldung.notfall": a("Anmeldung Notfall-Admin", "anmeldung", false),
|
||||
"anmeldung.notfall.fehlgeschlagen": a("Notfall-Anmeldung fehlgeschlagen", "anmeldung", false),
|
||||
"anmeldung.notfall.gesperrt": a("Notfall-Anmeldung gesperrt", "anmeldung", false),
|
||||
"anmeldung.notfall.totp_falsch": a("Notfall-Anmeldung: TOTP falsch", "anmeldung", false),
|
||||
"bestaetigung.oidc": a("Anmeldung bestätigt (Re-Auth)", "anmeldung", false),
|
||||
"notfall.totp_eingerichtet": a("TOTP für Notfall-Admin eingerichtet", "anmeldung"),
|
||||
|
||||
// Host-Keys (#009)
|
||||
"hostkey.abgerufen": a("Host-Key abgerufen", "hostkey", false),
|
||||
"hostkey.bestaetigt": a("Host-Key bestätigt", "hostkey"),
|
||||
"hostkey.ersetzt": a("Host-Key ersetzt", "hostkey"),
|
||||
"hostkey.abgelehnt": a("Host-Key abgelehnt", "hostkey"),
|
||||
"hostkey.abweichung": a("Host-Key weicht ab – Verbindung abgebrochen", "hostkey", false),
|
||||
|
||||
// Ausrollen (#016, #017)
|
||||
"authorized_keys.geschrieben": a("authorized_keys geschrieben", "ausrollen"),
|
||||
"privatekey.ausgerollt": a("Private Key ausgerollt", "ausrollen"),
|
||||
"verwendung.public.angelegt": a("Public Key zugewiesen", "ausrollen"),
|
||||
"verwendung.public.entfernen": a("Public-Key-Zuweisung zum Entfernen vorgemerkt", "ausrollen"),
|
||||
"verwendung.private.angelegt": a("Private Key zugewiesen", "ausrollen"),
|
||||
"verwendung.private.entfernen": a("Private-Key-Zuweisung zum Entfernen vorgemerkt", "ausrollen"),
|
||||
|
||||
// Schluessel (#012, #013, #014)
|
||||
"schluessel.erzeugt": a("Schlüssel erzeugt", "schluessel"),
|
||||
"schluessel.importiert": a("Schlüssel importiert", "schluessel"),
|
||||
"schluessel.uebernommen": a("Schlüssel vom Host übernommen", "schluessel"),
|
||||
"schluessel.selbst_hinterlegt": a("Eigenen Public Key hinterlegt", "schluessel"),
|
||||
"schluessel.selbst_entfernt": a("Eigenen Public Key entfernt", "schluessel"),
|
||||
"schluessel.geaendert": a("Schlüssel geändert", "schluessel"),
|
||||
"privatekey.angefordert": a("Private Key angefordert", "schluessel", false),
|
||||
"privatekey.angezeigt": a("Private Key angezeigt", "schluessel", false),
|
||||
"privatekey.heruntergeladen": a("Private Key heruntergeladen", "schluessel", false),
|
||||
"privatekey.geloescht": a("Private Key gelöscht", "schluessel"),
|
||||
"deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"),
|
||||
"deploymentkey.geprueft": a("Neuer Deployment-Key geprüft", "widerruf", false),
|
||||
"deploymentkey.umgestellt": a("Deployment-Key umgestellt", "widerruf"),
|
||||
"deploymentkey.abgeschlossen": a("Rotation des Deployment-Keys abgeschlossen", "widerruf"),
|
||||
|
||||
// Widerruf und Rotation (#023, #024)
|
||||
"schluessel.widerruf.eingeleitet": a("Widerruf eingeleitet", "widerruf"),
|
||||
"schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"),
|
||||
"schluessel.rotiert": a("Schlüssel rotiert", "widerruf"),
|
||||
"schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"),
|
||||
"schluessel.rotation.abgebrochen": a("Rotation abgebrochen", "widerruf"),
|
||||
|
||||
// Zielkonten (#018)
|
||||
"zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"),
|
||||
"zielkonto.geaendert": a("Zielkonto geändert", "zielkonto"),
|
||||
"zielkonto.entfernt": a("Zielkonto entfernt", "zielkonto"),
|
||||
|
||||
// Abgleich und Uebernahme (#021, #022)
|
||||
abgleich: a("Abgleich durchgeführt", "abgleich", false),
|
||||
"uebernahme.verwalten": a("Fremden Key übernommen und verwaltet", "abgleich"),
|
||||
"uebernahme.bekannt": a("Fremden Key als bekannt aufgenommen", "abgleich"),
|
||||
|
||||
// Hosts, Gruppen, Kunden (#008, #010)
|
||||
"host.angelegt": a("Host angelegt", "hosts"),
|
||||
"host.geaendert": a("Host geändert", "hosts"),
|
||||
"host.archiviert": a("Host archiviert", "hosts"),
|
||||
"host.bootstrap.angefordert": a("Einrichtung angefordert", "hosts", false),
|
||||
"host.bootstrap": a("Host eingerichtet (Bootstrap)", "hosts"),
|
||||
"host.bootstrap.geprueft": a("Einrichtung geprüft", "hosts"),
|
||||
"hostgruppe.angelegt": a("Hostgruppe angelegt", "hosts"),
|
||||
"hostgruppe.geaendert": a("Hostgruppe geändert", "hosts"),
|
||||
"kunde.angelegt": a("Kunde angelegt", "hosts"),
|
||||
verbindungstest: a("Verbindung geprüft", "hosts", false),
|
||||
|
||||
// Personen und Vorlagen (#013, #019)
|
||||
"person.angelegt": a("Person angelegt", "stammdaten"),
|
||||
"person.geaendert": a("Person geändert", "stammdaten"),
|
||||
"person.archiviert": a("Person archiviert", "stammdaten"),
|
||||
"person.verknuepft": a("Person mit Konto verknüpft", "stammdaten"),
|
||||
"vorlage.angelegt": a("Vorlage angelegt", "stammdaten"),
|
||||
"vorlage.geaendert": a("Vorlage geändert", "stammdaten"),
|
||||
"vorlage.archiviert": a("Vorlage archiviert", "stammdaten"),
|
||||
|
||||
// Protokoll selbst (#027)
|
||||
"protokoll.exportiert": a("Protokoll exportiert (CSV)", "protokoll", false),
|
||||
"protokoll.kette_beschaedigt": a("Hash-Kette beschädigt gefunden", "protokoll", false),
|
||||
};
|
||||
|
||||
/** Text zur Aktion; unbekannte (etwa aus aelteren Versionen) bleiben technisch lesbar. */
|
||||
export function aktionText(aktion: string): string {
|
||||
return AKTIONEN[aktion]?.text ?? aktion;
|
||||
}
|
||||
|
||||
/** Alle bekannten Aktionen einer Gruppe – Grundlage fuer den Filter `aktion IN (…)`. */
|
||||
export function aktionenDerGruppe(gruppe: Aktionsgruppe): string[] {
|
||||
return Object.entries(AKTIONEN)
|
||||
.filter(([, b]) => b.gruppe === gruppe)
|
||||
.map(([aktion]) => aktion);
|
||||
}
|
||||
|
||||
export type AktionsTon = "ok" | "gefahr" | "neutral";
|
||||
|
||||
/** Farbe in der Liste: Fehlschlag und Verweigerung immer --danger, Aenderungen --ok. */
|
||||
export function aktionTon(aktion: string, ergebnis: "ERFOLG" | "FEHLER" | "VERWEIGERT"): AktionsTon {
|
||||
if (ergebnis !== "ERFOLG") return "gefahr";
|
||||
return AKTIONEN[aktion]?.aendert ? "ok" : "neutral";
|
||||
}
|
||||
|
||||
export function istAktionsgruppe(wert: string): wert is Aktionsgruppe {
|
||||
return Object.hasOwn(AKTIONSGRUPPEN, wert);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { istAktionsgruppe, type Aktionsgruppe } from "@/lib/protokoll-aktionen";
|
||||
import { ZEITZONE } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* Filter der Protokollansicht (#027). Framework-frei: Seite, CSV-Route und Server Action
|
||||
* lesen dieselben GET-Parameter, damit der Export genau den Ausschnitt der Seite liefert.
|
||||
*/
|
||||
export const PROTOKOLL_ERGEBNISSE = ["ERFOLG", "FEHLER", "VERWEIGERT"] as const;
|
||||
export type ProtokollErgebnisWert = (typeof PROTOKOLL_ERGEBNISSE)[number];
|
||||
|
||||
export const ERGEBNIS_TEXT: Record<ProtokollErgebnisWert, string> = {
|
||||
ERFOLG: "Erfolg",
|
||||
FEHLER: "Fehler",
|
||||
VERWEIGERT: "Verweigert",
|
||||
};
|
||||
|
||||
export type ProtokollFilter = {
|
||||
/** Tag im Format JJJJ-MM-TT (Europe/Berlin), einschliesslich. */
|
||||
readonly von: string;
|
||||
readonly bis: string;
|
||||
readonly gruppe: Aktionsgruppe | "";
|
||||
readonly akteur: string;
|
||||
readonly host: string;
|
||||
readonly ergebnis: ProtokollErgebnisWert | "";
|
||||
readonly q: string;
|
||||
};
|
||||
|
||||
export const LEERER_FILTER: ProtokollFilter = {
|
||||
von: "",
|
||||
bis: "",
|
||||
gruppe: "",
|
||||
akteur: "",
|
||||
host: "",
|
||||
ergebnis: "",
|
||||
q: "",
|
||||
};
|
||||
|
||||
const TAG = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function gueltigerTag(wert: string): string {
|
||||
if (!TAG.test(wert)) return "";
|
||||
return Number.isNaN(new Date(`${wert}T00:00:00Z`).getTime()) ? "" : wert;
|
||||
}
|
||||
|
||||
export function protokollFilterAus(
|
||||
parameter: Record<string, string | string[] | undefined>,
|
||||
): ProtokollFilter {
|
||||
const einzeln = (name: string) => {
|
||||
const wert = parameter[name];
|
||||
return ((Array.isArray(wert) ? wert[0] : wert) ?? "").trim().slice(0, 200);
|
||||
};
|
||||
const gruppe = einzeln("gruppe");
|
||||
const ergebnis = einzeln("ergebnis");
|
||||
return {
|
||||
von: gueltigerTag(einzeln("von")),
|
||||
bis: gueltigerTag(einzeln("bis")),
|
||||
gruppe: istAktionsgruppe(gruppe) ? gruppe : "",
|
||||
akteur: einzeln("akteur"),
|
||||
host: einzeln("host"),
|
||||
ergebnis: (PROTOKOLL_ERGEBNISSE as readonly string[]).includes(ergebnis)
|
||||
? (ergebnis as ProtokollErgebnisWert)
|
||||
: "",
|
||||
q: einzeln("q"),
|
||||
};
|
||||
}
|
||||
|
||||
export function protokollFilterAktiv(filter: ProtokollFilter): boolean {
|
||||
return Object.values(filter).some(Boolean);
|
||||
}
|
||||
|
||||
/** Dieselben Filter als Query-String (CSV-Link, „ältere laden“, Anker auf eine ID). */
|
||||
export function protokollFilterQuery(filter: ProtokollFilter, extra: Record<string, string> = {}): string {
|
||||
const p = new URLSearchParams();
|
||||
for (const [name, wert] of Object.entries(filter)) if (wert) p.set(name, wert);
|
||||
for (const [name, wert] of Object.entries(extra)) p.set(name, wert);
|
||||
const text = p.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
/** Cursor „älter als ID“; nur positive Ganzzahlen, sonst keiner. */
|
||||
export function cursorAus(wert: string | string[] | undefined): bigint | undefined {
|
||||
const text = Array.isArray(wert) ? wert[0] : wert;
|
||||
if (!text || !/^\d{1,19}$/.test(text)) return undefined;
|
||||
const zahl = BigInt(text);
|
||||
return zahl > 0n ? zahl : undefined;
|
||||
}
|
||||
|
||||
/** Abstand Europe/Berlin zu UTC in Millisekunden zu einem Zeitpunkt (Sommer-/Winterzeit). */
|
||||
function berlinVersatz(zeitpunkt: Date): number {
|
||||
const teile = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: ZEITZONE,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).formatToParts(zeitpunkt);
|
||||
const wert = (typ: string) => Number(teile.find((t) => t.type === typ)?.value ?? 0);
|
||||
const alsUtc = Date.UTC(
|
||||
wert("year"),
|
||||
wert("month") - 1,
|
||||
wert("day"),
|
||||
wert("hour"),
|
||||
wert("minute"),
|
||||
wert("second"),
|
||||
);
|
||||
return alsUtc - Math.floor(zeitpunkt.getTime() / 1000) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mitternacht des Tages in Berlin als Zeitpunkt. Die Oberflaeche zeigt Berliner Zeit (#002),
|
||||
* also muss „von 12.10.“ auch Berliner Mitternacht meinen – nicht UTC.
|
||||
*/
|
||||
export function berlinTagesbeginn(tag: string): Date {
|
||||
const utcMitternacht = new Date(`${tag}T00:00:00Z`);
|
||||
const erst = new Date(utcMitternacht.getTime() - berlinVersatz(utcMitternacht));
|
||||
// Zweiter Schritt fuer Tage, an denen die Uhr umgestellt wird.
|
||||
return new Date(utcMitternacht.getTime() - berlinVersatz(erst));
|
||||
}
|
||||
|
||||
/** Zeitraum als halboffenes Intervall [ab, vor) – „bis“ schliesst den ganzen Tag ein. */
|
||||
export function zeitraum(filter: Pick<ProtokollFilter, "von" | "bis">): { ab?: Date; vor?: Date } {
|
||||
const ergebnis: { ab?: Date; vor?: Date } = {};
|
||||
if (filter.von) ergebnis.ab = berlinTagesbeginn(filter.von);
|
||||
if (filter.bis) {
|
||||
const folgetag = new Date(`${filter.bis}T00:00:00Z`);
|
||||
folgetag.setUTCDate(folgetag.getUTCDate() + 1);
|
||||
ergebnis.vor = berlinTagesbeginn(folgetag.toISOString().slice(0, 10));
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
/** „12.10.2026 08:42:17“ – Sekunden gehoeren ins Protokoll, Komma nicht (#027). */
|
||||
const ZEITSTEMPEL = new Intl.DateTimeFormat("de-DE", {
|
||||
timeZone: ZEITZONE,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
export function protokollZeit(wert: Date | string): string {
|
||||
return ZEITSTEMPEL.format(new Date(wert)).replace(",", "");
|
||||
}
|
||||
|
||||
export type ProtokollDiffZeile = { art: "hinzu" | "entfernt" | "kontext"; text: string };
|
||||
|
||||
/**
|
||||
* Der gespeicherte Diff ist Text mit `+ `/`- ` je Zeile (#016); Private-Key-Ausrollen schreibt
|
||||
* nur „anlegen ~/.ssh/…“ – solche Zeilen bleiben Kontext.
|
||||
*/
|
||||
export function diffZeilen(diff: string): ProtokollDiffZeile[] {
|
||||
return diff.split("\n").map((zeile) => {
|
||||
if (zeile.startsWith("+")) return { art: "hinzu", text: zeile.replace(/^\+ ?/, "") };
|
||||
if (zeile.startsWith("-")) return { art: "entfernt", text: zeile.replace(/^- ?/, "") };
|
||||
return { art: "kontext", text: zeile };
|
||||
});
|
||||
}
|
||||
|
||||
/** Spalten des CSV-Exports (#027): technisch, damit Auswertungen stabil bleiben. */
|
||||
export const PROTOKOLL_CSV_KOPF = [
|
||||
"id",
|
||||
"zeitpunkt",
|
||||
"akteur",
|
||||
"aktion",
|
||||
"ergebnis",
|
||||
"host",
|
||||
"zielbenutzer",
|
||||
"fingerprint",
|
||||
"diff",
|
||||
"details",
|
||||
"vorgaengerHash",
|
||||
"hash",
|
||||
] as const;
|
||||
|
||||
export type ProtokollCsvQuelle = {
|
||||
readonly id: bigint | string;
|
||||
readonly zeitpunkt: Date;
|
||||
readonly akteurName: string;
|
||||
readonly aktion: string;
|
||||
readonly ergebnis: string;
|
||||
readonly hostName: string | null;
|
||||
readonly zielbenutzer: string | null;
|
||||
readonly fingerprint: string | null;
|
||||
readonly diff: string | null;
|
||||
readonly details: unknown;
|
||||
readonly vorgaengerHash: string;
|
||||
readonly hash: string;
|
||||
};
|
||||
|
||||
export function protokollCsvWerte(z: ProtokollCsvQuelle): (string | null)[] {
|
||||
return [
|
||||
String(z.id),
|
||||
z.zeitpunkt.toISOString(),
|
||||
z.akteurName,
|
||||
z.aktion,
|
||||
z.ergebnis,
|
||||
z.hostName,
|
||||
z.zielbenutzer,
|
||||
z.fingerprint,
|
||||
z.diff,
|
||||
z.details == null ? null : JSON.stringify(z.details),
|
||||
z.vorgaengerHash,
|
||||
z.hash,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Namens- und Pfadregeln der gefuehrten Rotation (#023). Rein, damit testbar.
|
||||
*/
|
||||
|
||||
const NAME_SUFFIX = /\s*\(rotiert \d{2}\.\d{2}\.\d{4}\)$/;
|
||||
const PFAD_SUFFIX = /-r\d{8}(?:-\d+)?$/;
|
||||
|
||||
/** TT.MM.JJJJ und JJJJMMTT nach dem Kalender in Berlin. */
|
||||
export function rotationsDatum(jetzt: Date = new Date()): { anzeige: string; kompakt: string } {
|
||||
const [jahr, monat, tag] = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" })
|
||||
.format(jetzt)
|
||||
.split("-");
|
||||
return { anzeige: `${tag}.${monat}.${jahr}`, kompakt: `${jahr}${monat}${tag}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* „Backup A → B (rotiert 12.10.2026)“ – ein frueheres Rotations-Suffix wird ersetzt, damit
|
||||
* die Namen bei jeder Rotation nicht laenger werden.
|
||||
*/
|
||||
export function rotationsName(name: string, datum: string, maxLaenge = 200): string {
|
||||
const suffix = ` (rotiert ${datum})`;
|
||||
const basis = name.replace(NAME_SUFFIX, "").trim();
|
||||
return `${basis.slice(0, Math.max(1, maxLaenge - suffix.length))}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuer Pfad fuer den Private Key des Nachfolgers: gleicher Name mit `-rJJJJMMTT`. Ein alter
|
||||
* Suffix wird ersetzt; ist der Pfad (auch durch eine fruehere, entfernte Zuweisung) belegt,
|
||||
* kommt eine laufende Nummer dazu. Zwei Dateien nebeneinander sind gewollt: bis der Nachfolger
|
||||
* getestet ist, bleibt der alte Schluessel benutzbar.
|
||||
*/
|
||||
export function rotationsPfad(pfad: string, datumKompakt: string, belegt: ReadonlySet<string>): string {
|
||||
const basis = pfad.replace(PFAD_SUFFIX, "");
|
||||
let kandidat = `${basis}-r${datumKompakt}`;
|
||||
for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`;
|
||||
return kandidat;
|
||||
}
|
||||
|
||||
/** Laedt nur den juengsten Nachfolger – nach einem Abbruch kann es mehrere geben (#033). */
|
||||
export const JUENGSTER = { orderBy: { angelegtAm: "desc" }, take: 1 } as const;
|
||||
|
||||
/**
|
||||
* Macht aus `nachfolgerListe` (hoechstens ein Element, siehe JUENGSTER) wieder `nachfolger`.
|
||||
* Massgeblich ist immer der juengste: ein laufender oder – wenn keiner laeuft – der zuletzt
|
||||
* abgebrochene.
|
||||
*/
|
||||
export type MitNachfolger<T extends { nachfolgerListe: readonly unknown[] }> = Omit<T, "nachfolgerListe"> & {
|
||||
nachfolger: T["nachfolgerListe"][number] | null;
|
||||
};
|
||||
|
||||
export function mitNachfolger<T extends { nachfolgerListe: readonly unknown[] }>(s: T): MitNachfolger<T> {
|
||||
const { nachfolgerListe, ...rest } = s;
|
||||
return { ...rest, nachfolger: nachfolgerListe[0] ?? null };
|
||||
}
|
||||
@@ -200,6 +200,8 @@ export type SchluesselVorgangEingabe = {
|
||||
readonly typ?: "ED25519" | "RSA4096";
|
||||
/** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */
|
||||
readonly mitPassphrase?: boolean;
|
||||
/** Rotation (#023): der abgeloeste Schluessel – so ist die Kette sofort da, auch ohne Browser. */
|
||||
readonly vorgaengerId?: string | null;
|
||||
};
|
||||
|
||||
/** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */
|
||||
@@ -217,6 +219,7 @@ export const vorgangEingabeSchema = z.object({
|
||||
.nullable(),
|
||||
typ: z.enum(["ED25519", "RSA4096"]).optional(),
|
||||
mitPassphrase: z.boolean().optional(),
|
||||
vorgaengerId: z.string().max(64).nullish(),
|
||||
});
|
||||
|
||||
/** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */
|
||||
@@ -290,3 +293,14 @@ export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): st
|
||||
const [typ, base64] = zeile.trim().split(/\s+/);
|
||||
return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs aus einem Suchparameter (`?id=a&id=b` oder `?id=a,b`), etwa fuer den Massenwiderruf (#024).
|
||||
* Erlaubt Bindestriche: Vom Worker erzeugte Schluessel tragen eine UUID, nur Prisma-Defaults sind
|
||||
* cuids – ohne Bindestrich fielen alle erzeugten Schluessel still aus der Auswahl (#032).
|
||||
*/
|
||||
export function idsAusSuchparameter(wert: string | string[] | undefined, hoechstens = 200): string[] {
|
||||
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
|
||||
const gueltig = roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9-]{1,64}$/i.test(w));
|
||||
return [...new Set(gueltig)].slice(0, hoechstens);
|
||||
}
|
||||
|
||||