Compare commits
@@ -59,6 +59,25 @@ SSHKV_BREAKGLASS_PASSWORD=
|
|||||||
# --- Worker --------------------------------------------------------------------
|
# --- Worker --------------------------------------------------------------------
|
||||||
# Zeitplan fuer den Abgleich aller Hosts (cron)
|
# Zeitplan fuer den Abgleich aller Hosts (cron)
|
||||||
SSHKV_SYNC_CRON=30 2 * * *
|
SSHKV_SYNC_CRON=30 2 * * *
|
||||||
|
# Taegliche Zusammenfassung der Benachrichtigungen (cron, Europe/Berlin, #028). Geaenderte
|
||||||
|
# Host-Keys meldet der Worker unabhaengig davon alle fuenf Minuten. Kanaele und Anlaesse
|
||||||
|
# stellt ein Admin unter /einstellungen ein.
|
||||||
|
SSHKV_MELDUNG_CRON=0 7 * * *
|
||||||
|
|
||||||
|
# --- Syslog (optional, #027) ---------------------------------------------------
|
||||||
|
# Jeder Protokolleintrag geht zusaetzlich als RFC-5424-Zeile an einen Syslog-Server – die
|
||||||
|
# Kopie ausserhalb von sshkv. Ohne SSHKV_SYSLOG_HOST ist die Weiterleitung aus. Es gehen nur
|
||||||
|
# Akteur, Aktion, Ergebnis, Host, Zielbenutzer, Fingerprint und Hash hinaus, nie Details/Diff.
|
||||||
|
# Web und Worker protokollieren beide; beide brauchen die Variablen.
|
||||||
|
# SSHKV_SYSLOG_HOST=syslog.intern
|
||||||
|
# Standard 514
|
||||||
|
# SSHKV_SYSLOG_PORT=514
|
||||||
|
# udp (Standard) oder tcp (Oktettzaehlung nach RFC 6587)
|
||||||
|
# SSHKV_SYSLOG_PROTOKOLL=udp
|
||||||
|
# APP-NAME im Syslog-Kopf (Standard sshkv)
|
||||||
|
# SSHKV_SYSLOG_APPNAME=sshkv
|
||||||
|
# Facility (Standard authpriv; auch auth, daemon, user, local0 … local7)
|
||||||
|
# SSHKV_SYSLOG_FACILITY=authpriv
|
||||||
|
|
||||||
# --- Integrationstests (#011) ---------------------------------------------------
|
# --- Integrationstests (#011) ---------------------------------------------------
|
||||||
# SSH-Testziele aus docker-compose.test.yml. Die Compose-Datei liest dieselben Variablen
|
# SSH-Testziele aus docker-compose.test.yml. Die Compose-Datei liest dieselben Variablen
|
||||||
@@ -70,3 +89,8 @@ SSHKV_TEST_ROOT_PASSWORT=sshkv-test
|
|||||||
SSHKV_TEST_TEST_PASSWORT=sshkv-test
|
SSHKV_TEST_TEST_PASSWORT=sshkv-test
|
||||||
# Mock-OIDC (Port-Bindung)
|
# Mock-OIDC (Port-Bindung)
|
||||||
SSHKV_TEST_OIDC=127.0.0.1:8090
|
SSHKV_TEST_OIDC=127.0.0.1:8090
|
||||||
|
# Mailpit fuer tests/integration/benachrichtigung.test.ts (#028), getrennt zu starten:
|
||||||
|
# docker run -d --name sshkv-mailpit -e MP_SMTP_DISABLE_RDNS=true \
|
||||||
|
# -p 127.0.0.1:1026:1025 -p 127.0.0.1:8026:8025 axllent/mailpit
|
||||||
|
SSHKV_TEST_SMTP=127.0.0.1:1026
|
||||||
|
SSHKV_TEST_MAILPIT_API=http://127.0.0.1:8026
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
- Geführte Rotation des Deployment-Keys in fünf Schritten (erzeugen, verteilen, prüfen, umstellen, aufräumen) – kein Host steht dabei ohne funktionierenden Key da; nicht erreichbare Hosts lassen sich bewusst übergehen (#031)
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
@@ -15,6 +16,25 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.5.0] – 2026-10-12
|
||||||
|
|
||||||
|
Phase 5: Übersicht, Audit-Log, Benachrichtigungen, Handbuch und Betrieb.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Übersicht mit echten Zahlen: Kennzahlen, Hosts mit Abweichung, bald ablaufende und abgelaufene Schlüssel, letzte Aktionen (#026)
|
||||||
|
- Audit-Log: Filter, aufklappbare Einträge mit Diff und Hashes, Prüfung der Hash-Kette, CSV-Export (gestreamt), optionale Syslog-Kopie nach RFC 5424 (#027)
|
||||||
|
- Benachrichtigungen per E-Mail (Microsoft 365 SMTP vorbelegt) und Webhook mit Signatur: tägliche Zusammenfassung zu Drift, Ablauf und nicht erreichbaren Hosts, geänderte Host-Keys sofort; Testversand, Versandprotokoll ohne Doppelmeldungen; Einstellungsseite (#028)
|
||||||
|
- Handbuch mit Bildern, Installation mit authentik und Reverse Proxy, Bedrohungsanalyse mit Notfallplan (#029)
|
||||||
|
- `deploy/sichern.sh` und `deploy/wiederherstellen.sh` – Wiederherstellung auf einem Wegwerf-Stapel geprobt (#029)
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Der Worker startet nicht, wenn der Master-Key nicht zur Datenbank passt (#029)
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Sicherung ohne Master-Key, nur mit Prüfwert; Wiederherstellung bricht vor jeder Änderung ab, wenn der Master-Key nicht passt, und läuft in einer Transaktion
|
||||||
|
- SMTP-Passwort und Webhook-Geheimnis verschlüsselt gespeichert und nie wieder angezeigt; Webhook nur über https, ohne Weiterleitungen
|
||||||
|
- Syslog-Kopie enthält nie Details oder Diffs
|
||||||
|
|
||||||
## [0.4.0] – 2026-10-12
|
## [0.4.0] – 2026-10-12
|
||||||
|
|
||||||
Phase 4: Abgleich, Import, Rotation, Widerruf und Zuweisungsmatrix.
|
Phase 4: Abgleich, Import, Rotation, Widerruf und Zuweisungsmatrix.
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt (Wandzeit):** 3 h 40 min
|
**Gesamt (Wandzeit):** 4 h 25 min
|
||||||
**Summe der Issue-Zeiten:** 11 h 03 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025.
|
**Summe der Issue-Zeiten:** 13 h 18 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027 und #028.
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -33,6 +33,10 @@
|
|||||||
| #023 Geführte Rotation | 0 h 20 min |
|
| #023 Geführte Rotation | 0 h 20 min |
|
||||||
| #024 Widerruf und Massenwiderruf | 0 h 25 min |
|
| #024 Widerruf und Massenwiderruf | 0 h 25 min |
|
||||||
| #025 Zuweisungsmatrix | 0 h 30 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 (vorbereitet) | 0 h 05 min |
|
| #030 Gitea-Abgleich (vorbereitet) | 0 h 05 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
@@ -41,6 +45,7 @@
|
|||||||
| v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen**
|
| v0.2.0 | 1 h 00 min (Wandzeit) | **abgeschlossen**
|
||||||
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
||||||
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||||
|
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,3 +131,21 @@
|
|||||||
- **Widerruf mit Zwischenzustand:** Ein Schlüssel wird erst WIDERRUFEN, wenn er überall entfernt ist; ein nicht erreichbarer Host lässt ihn erkennbar „unvollständig“.
|
- **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.
|
- **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).
|
- **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.
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ Selbst gehostete Weboberfläche zur zentralen Verwaltung von SSH-Schlüsseln: Sc
|
|||||||
erzeugen und verwalten, Public Keys in `authorized_keys` verteilen, Private Keys gezielt auf
|
erzeugen und verwalten, Public Keys in `authorized_keys` verteilen, Private Keys gezielt auf
|
||||||
andere Hosts ausrollen, Ist- und Soll-Zustand abgleichen – alles revisionssicher protokolliert.
|
andere Hosts ausrollen, Ist- und Soll-Zustand abgleichen – alles revisionssicher protokolliert.
|
||||||
|
|
||||||
> **Stand 0.1.0 (Phase 1):** Grundgerüst, Anmeldung (OIDC + Notfall-Admin mit TOTP), Hosts und
|
> **Stand 0.5.0:** alle fünf Phasen aus [`docs/PLAN.md`](docs/PLAN.md) umgesetzt – Hosts mit Host-Key-Prüfung
|
||||||
> Hostgruppen, Host-Key-Prüfung (TOFU) und Bootstrap neuer Hosts. Schlüsselverwaltung und
|
> und Bootstrap, Schlüssel, Ausrollen von Public und Private Keys mit Dry-Run, Host-zu-Host-Assistent,
|
||||||
> Ausrollen folgen in Phase 2 und 3 – siehe [`docs/PLAN.md`](docs/PLAN.md).
|
> Abgleich, Rotation, Widerruf, Zuweisungsmatrix, Audit-Log, Benachrichtigungen. 1.0.0 nach der Abnahme.
|
||||||
|
|
||||||
## Sicherheit vorab
|
## Sicherheit vorab
|
||||||
|
|
||||||
@@ -18,7 +18,15 @@ SSHKeyVault hat **root-Zugriff auf alle verwalteten Hosts**. Deshalb:
|
|||||||
- Private Keys liegen nur verschlüsselt in der Datenbank (AES-256-GCM, Envelope Encryption);
|
- Private Keys liegen nur verschlüsselt in der Datenbank (AES-256-GCM, Envelope Encryption);
|
||||||
entschlüsseln kann ausschließlich der Worker, der Webprozess hat den Master-Key nicht.
|
entschlüsseln kann ausschließlich der Worker, der Webprozess hat den Master-Key nicht.
|
||||||
|
|
||||||
Mehr dazu in [`docs/PLAN.md`](docs/PLAN.md), Abschnitt 3.
|
Mehr dazu in [`docs/betrieb/sicherheit.md`](docs/betrieb/sicherheit.md) (mit Bedrohungsanalyse und
|
||||||
|
Notfallplan) und [`docs/betrieb/sicherung.md`](docs/betrieb/sicherung.md).
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
- [Handbuch](docs/anleitung/README.md) – Bedienung Schritt für Schritt mit Bildern
|
||||||
|
- [Installation und Betrieb](docs/betrieb/installation.md) – Docker Compose, authentik, Reverse Proxy
|
||||||
|
- [Sicherung und Wiederherstellung](docs/betrieb/sicherung.md) – `deploy/sichern.sh`, `deploy/wiederherstellen.sh`
|
||||||
|
- [Sicherheit](docs/betrieb/sicherheit.md) – Aufbau, Bedrohungsanalyse, Notfallplan
|
||||||
|
|
||||||
## Installation (Docker Compose)
|
## 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_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
|
||||||
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
||||||
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
||||||
|
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
|
||||||
|
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
|
||||||
|
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
|
||||||
|
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
|
||||||
|
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
|
||||||
|
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
|
||||||
|
# Nur zur Anzeige unter /einstellungen; ausgefuehrt werden die Zeitplaene im Worker.
|
||||||
|
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
|
||||||
|
SSHKV_MELDUNG_CRON: ${SSHKV_MELDUNG_CRON:-0 7 * * *}
|
||||||
# Absichtlich KEIN Master-Key: eine Luecke im Webprozess allein entschluesselt keine
|
# Absichtlich KEIN Master-Key: eine Luecke im Webprozess allein entschluesselt keine
|
||||||
# Private Keys. Nach aussen braucht er nur den OIDC-Anbieter.
|
# Private Keys. Nach aussen braucht er nur den OIDC-Anbieter.
|
||||||
ports:
|
ports:
|
||||||
@@ -94,9 +103,19 @@ services:
|
|||||||
SSHKV_APP_SECRET: ${SSHKV_APP_SECRET}
|
SSHKV_APP_SECRET: ${SSHKV_APP_SECRET}
|
||||||
SSHKV_MASTER_KEY_FILE: /run/secrets/master_key
|
SSHKV_MASTER_KEY_FILE: /run/secrets/master_key
|
||||||
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
|
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
|
||||||
|
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
|
||||||
|
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
|
||||||
|
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
|
||||||
|
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
|
||||||
|
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
|
||||||
|
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
|
||||||
|
# Benachrichtigungen (#028): taegliche Zusammenfassung; Links in den Nachrichten zeigen
|
||||||
|
# auf SSHKV_APP_URL. Den Weg zu Mailserver und Webhook-Ziel hat der Worker ueber "aussen".
|
||||||
|
SSHKV_MELDUNG_CRON: ${SSHKV_MELDUNG_CRON:-0 7 * * *}
|
||||||
|
SSHKV_APP_URL: ${SSHKV_APP_URL}
|
||||||
secrets:
|
secrets:
|
||||||
- master_key
|
- master_key
|
||||||
# Der Worker braucht die Datenbank und den Weg zu den Hosts.
|
# Der Worker braucht die Datenbank und den Weg zu den Hosts (und zu Mailserver/Webhook).
|
||||||
networks: [datenbank, aussen]
|
networks: [datenbank, aussen]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"]
|
test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"]
|
||||||
|
|||||||
@@ -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“).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 481 KiB |
|
Before Width: | Height: | Size: 144 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 |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 293 KiB |
@@ -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,19 +1,24 @@
|
|||||||
# #026 Übersicht (Dashboard)
|
# #026 Übersicht (Dashboard)
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** ui
|
**Labels:** ui
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 25 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen.
|
Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen aus der Datenbank
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Kennzahlen, Hosts mit Abweichung, bald ablaufend, letzte Aktionen aus der Datenbank
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#021
|
#021
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Alles berechnet, nichts hinterlegt** (`src/server/queries/uebersicht.ts`): Kacheln und Listen können einander nicht widersprechen. Die Prototyp-Beispieldaten sind entfernt.
|
||||||
|
- **Hosts mit Abweichung** fasst Befunde je Host und Benutzer zusammen („2 unbekannte Schlüssel · Rechte falsch“, feste Reihenfolge) und zeigt nicht erreichbare Hosts; Link direkt auf die Hostseite.
|
||||||
|
- **Bald ablaufend** zeigt auch bereits abgelaufene, noch aktive Schlüssel („abgelaufen · noch ausgerollt“ in Koralle).
|
||||||
|
- **Letzte Aktionen** ohne Anmeldungen – die stehen im Audit-Log.
|
||||||
|
- Knöpfe nur für passende Rollen (Abgleich: Hosts verwalten; Assistent: Private Keys ausrollen).
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
# #027 Protokollansicht, CSV-Export, Syslog
|
# #027 Protokollansicht, CSV-Export, Syslog
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** ui, backend
|
**Labels:** ui, backend
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog.
|
Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] `/protokoll` statt Platzhalter, jede Rolle liest: Tabelle mit Zeitpunkt (TT.MM.JJJJ HH:MM:SS), Aktion (lesbar, farbig), Akteur, Host, Zielbenutzer, Fingerprint (gekürzt), Ergebnis; unter `md` Karten
|
||||||
|
- [x] Zeile aufklappbar: Diff im Diff-Box-Stil, Details als Liste (Werte als JSON), Hash und Vorgänger-Hash
|
||||||
|
- [x] Lesbare Texte für alle Aktionen in `src/lib/protokoll-aktionen.ts`, gruppiert (Anmeldung, Host-Key, Ausrollen, Schlüssel, Widerruf/Rotation, Zielkonto, Abgleich, Hosts, Personen und Vorlagen, Protokoll)
|
||||||
|
- [x] Filter per GET: Zeitraum (Berliner Kalendertage), Aktionsgruppe, Akteur, Host, Ergebnis, Freitext (Fingerprint/Host/Benutzer)
|
||||||
|
- [x] 50 je Seite, „Ältere laden“ hängt per Server Action an; Cursor über `id`, kein `OFFSET`; `?vor=<id>` als Einstieg
|
||||||
|
- [x] Kopfzeile mit Anzahl und Syslog-Ziel; „Kette prüfen“ (Server Action) mit Ergebnis-Zwischenspeicher für 5 Minuten, Warnung mit Link auf die erste fehlerhafte ID
|
||||||
|
- [x] CSV-Export `/api/protokoll.csv`: eigene Rechteprüfung, gleiche Filter, gestreamt in Stapeln zu 500, Formel-Entschärfung über `src/lib/csv.ts`, Export selbst als `protokoll.exportiert` protokolliert
|
||||||
|
- [x] Kein Lösch-/Bearbeitungsweg; struktureller Test gegen `protokoll.update/delete…` und `UPDATE/DELETE/TRUNCATE "Protokoll"` in `src/`
|
||||||
|
- [x] Syslog nach RFC 5424 (`src/lib/syslog.ts`, UDP oder TCP mit Oktettzählung, ohne neue Abhängigkeit), eingehängt in `protokollieren()` nach dem Schreiben
|
||||||
|
- [x] `.env.example` und `docker-compose.yml` (web und worker) um `SSHKV_SYSLOG_*`
|
||||||
|
- [x] Tests: Query/Cursor/Filter, CSV-Route, Kettenprüfung, Syslog (Format, Escaping, Länge, UDP-/TCP-Empfänger), Aktionstexte, Struktur; E2E `tests/e2e/protokoll.spec.ts`
|
||||||
|
- [x] Screenshots `protokoll-desktop.png` und `protokoll-mobil.png`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Jede `aktion: "…"` im Quellcode hat einen lesbaren Text (Test)
|
||||||
|
- [x] Filter und „Ältere laden“ liefern weder Lücken noch Doppelte, auch wenn zwischendurch Einträge dazukommen
|
||||||
|
- [x] CSV ohne Sitzung → 403; Kopfzeile `id;zeitpunkt;akteur;aktion;…;hash`; große Protokolle werden in Stapeln gelesen
|
||||||
|
- [x] Manipulierte Zeile → Warnung mit ID; unversehrte Kette → „Hash-Kette unversehrt“
|
||||||
|
- [x] Ausgefallener Syslog-Server verhindert keine Aktion; Fehler höchstens einmal je Minute im Log
|
||||||
|
- [x] 1440 und 390 px ohne Querscrollen
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#005
|
#005
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Kettenprüfung nur auf Knopfdruck, 5 Minuten zwischengespeichert (je Prozess).** `protokoll_kette_pruefen()` rechnet SHA-256 über jede Zeile und wächst linear mit dem Protokoll; beim bloßen Seitenaufruf wäre das bei 100 000 Einträgen spürbar. Gleichzeitige Klicks teilen sich eine laufende Prüfung. Ein Bruch wird selbst als `protokoll.kette_beschaedigt` (FEHLER) protokolliert, damit er auch beim Syslog ankommt.
|
||||||
|
- **CSV gestreamt** über einen `ReadableStream` mit `pull`: je Stapel 500 Zeilen (Cursor über `id`), der nächste Stapel erst, wenn der Client den vorigen abgenommen hat. Der Exporteintrag wird vor dem ersten Byte geschrieben – scheitert er, gibt es keinen Export.
|
||||||
|
- **IDs sind nicht lückenlos** (Spalten-Default und Trigger ziehen beide aus der Sequenz); Cursor und Tests verlassen sich nur auf die Ordnung.
|
||||||
|
- **Syslog:** RFC 5424, SD-ID `sshkv@32473`, Parameter id/akteur/aktion/ergebnis/host/zielbenutzer/fingerprint/hash (escaped nach §6.3.3, Steuerzeichen ersetzt, je Wert ≤ 120 Byte, Zeile ≤ 2048 Byte), MSGID = Aktion, Severity ERFOLG=notice, VERWEIGERT=warning, FEHLER=err, Facility standardmäßig `authpriv` (`SSHKV_SYSLOG_FACILITY`). Diff und Details gehen nie hinaus. Fire-and-forget nach dem `create`; innerhalb einer Transaktion kann ein späterer Rollback eine Syslog-Zeile ohne Gegenstück hinterlassen – das ist die harmlosere Richtung (ihr Hash fehlt dann in der Kette).
|
||||||
|
- `--ok` nur für erfolgreiche Aktionen, die etwas ändern; Anmeldungen, Abrufe und Export bleiben neutral, Fehler/Verweigert immer `--danger`.
|
||||||
|
|||||||
@@ -1,19 +1,65 @@
|
|||||||
# #028 Benachrichtigungen
|
# #028 Benachrichtigungen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** backend
|
**Labels:** backend
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0,5 h
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
E-Mail (M365 SMTP) und Webhook bei Drift und Ablauf.
|
E-Mail (M365 SMTP) und Webhook bei Drift und Ablauf.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Einstellungsseite `/einstellungen` statt Platzhalter: lesen alle Rollen, ändern nur
|
||||||
|
`einstellungenVerwalten` (Admin). Abschnitte „Benachrichtigungen“, „Abgleich“ (Zeitpläne aus
|
||||||
|
`SSHKV_SYNC_CRON`/`SSHKV_MELDUNG_CRON`, nur Anzeige) und „Instanz“ (Version,
|
||||||
|
Deployment-Key-Fingerprint, OIDC-Anbieter, TOTP des Notfall-Admins). Platz für „Syslog“ markiert.
|
||||||
|
- [x] Kanal E-Mail (SMTP): Vorbelegung Microsoft 365 (`smtp.office365.com:587`, STARTTLS), wahlweise
|
||||||
|
TLS (465) oder unverschlüsselt (nur internes Relay, mit Warnung); Absender, Benutzer, Passwort,
|
||||||
|
Empfängerliste.
|
||||||
|
- [x] Kanal Webhook: JSON per POST, optional `X-Sshkv-Signatur: sha256=<HMAC>` über den Body; nur
|
||||||
|
`https://`, `http://` nur per Häkchen mit Warnung; keine Weiterleitungen, Zeitlimit 15 s.
|
||||||
|
- [x] Geheimnisse (SMTP-Passwort, Webhook-Geheimnis) mit `verschluesseln()` in `Einstellung`;
|
||||||
|
die Oberfläche bekommt nur „gesetzt“, leeres Feld behält, Häkchen entfernt.
|
||||||
|
- [x] „Testnachricht senden“ je Kanal als Vorgang `BENACHRICHTIGUNG_TEST` im Worker.
|
||||||
|
- [x] Anlässe (je einzeln schaltbar): neue Abweichungen je Host, Ablauf in 30/14/7 Tagen,
|
||||||
|
abgelaufen aber ausgerollt, Host nicht erreichbar – gesammelt in der täglichen Zusammenfassung
|
||||||
|
(`SSHKV_MELDUNG_CRON`, Standard `0 7 * * *`); geänderter Host-Key sofort (Takt alle 5 Minuten).
|
||||||
|
- [x] Versandprotokoll `Zustellung` (Migration `20261012161000_benachrichtigungen`) mit
|
||||||
|
Anlass-Fingerabdruck, Kanal, Ergebnis, Fehler, Versuchen; Prüfung vor jedem Versand,
|
||||||
|
Wiederholung nach 5/10/20/40 Minuten (höchstens 5 Versuche); „Letzte Zustellungen“ auf der Seite.
|
||||||
|
- [x] Jede Zustellung in `protokollieren()` als `benachrichtigung.gesendet`/`…fehlgeschlagen`
|
||||||
|
(Empfänger nur gezählt, Webhook nur Schema und Host).
|
||||||
|
- [x] Zeitpläne in `src/worker/index.ts`; `.env.example` und `docker-compose.yml` ergänzt.
|
||||||
|
- [x] Tests: Unit (Nachrichtenaufbau, Signatur, Stufen, Deduplizierung, Prüfungen), DB
|
||||||
|
(Einstellungen, Versandprotokoll), Integration gegen Mailpit und lokalen HTTP-Empfänger,
|
||||||
|
E2E `tests/e2e/einstellungen.spec.ts`; Screenshots `einstellungen-desktop/-mobil`.
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Admin speichert SMTP-Einstellungen, die Testnachricht kommt in Mailpit an (E2E).
|
||||||
|
- [x] Das Passwort steht nie im Klartext in Datenbank, Protokoll oder Seite; das Feld zeigt nur „gesetzt“.
|
||||||
|
- [x] Leser sieht die Seite ohne Ändern- und Test-Knöpfe.
|
||||||
|
- [x] Dieselbe Abweichung, dieselbe Ablaufstufe und derselbe Ausfall werden je Kanal genau einmal gemeldet.
|
||||||
|
- [x] Fehlgeschlagene Zustellungen werden begrenzt wiederholt und sind in der Oberfläche sichtbar.
|
||||||
|
- [x] Ein geänderter Host-Key wird innerhalb von fünf Minuten gemeldet, ohne den Abgleich-Ablauf zu ändern.
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#021
|
#021
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Versand im Worker**, auch für die Testnachricht: der Webprozess braucht so keinen Weg zu
|
||||||
|
Mailserver oder Webhook-Ziel, und regulärer Versand und Test laufen über denselben Code. Die
|
||||||
|
Oberfläche verfolgt den Test wie andere Vorgänge über `VorgangsKnopf`.
|
||||||
|
- **Anlässe aus der Datenbank**, nicht aus dem Abgleich: `src/worker/ablaeufe/abgleich.ts` bleibt
|
||||||
|
unverändert; der Sammler liest offene `Abweichung`, `Host.erreichbar = false` und Ablaufdaten.
|
||||||
|
Ein verpasster Lauf holt sich der nächste.
|
||||||
|
- **Fingerabdrücke:** `drift:<abweichungId>`, `hostkey:<abweichungId>`,
|
||||||
|
`ablauf:<schluesselId>:<stufe>:<ablaufdatum>` (mit Datum, damit ein verlängerter Schlüssel neu
|
||||||
|
erinnert wird), `abgelaufen:<schluesselId>:<ablaufdatum>`,
|
||||||
|
`unerreichbar:<hostId>:<letzterAbgleichAm>` (ändert sich nur bei gelungenem Abgleich → eine Meldung
|
||||||
|
je Ausfall). Statt „seit der letzten Meldung“ zählt das Versandprotokoll – wer Benachrichtigungen
|
||||||
|
erstmals einschaltet, bekommt die offenen Abweichungen einmal gemeldet.
|
||||||
|
- Hosts mit offenem `HOSTKEY_GEAENDERT` erscheinen nicht zusätzlich als „nicht erreichbar“.
|
||||||
|
- Der Fünf-Minuten-Takt verschickt dringende Host-Key-Meldungen und fällige Wiederholungen;
|
||||||
|
Neues aus (a)–(d) wartet auf die Zusammenfassung. Läufe im selben Worker laufen nacheinander.
|
||||||
|
- Leser sehen statt der Webhook-Adresse nur Schema und Host (Tokens im Pfad).
|
||||||
|
- Mailpit für Tests mit `MP_SMTP_DISABLE_RDNS=true` starten, sonst wartet es je Verbindung 5 s.
|
||||||
|
- `nodemailer@10.0.10` wie in ProxLedger, exakt gepinnt.
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
# #029 Handbuch und Betriebsdokumentation
|
# #029 Handbuch und Betriebsdokumentation
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** docs
|
**Labels:** docs
|
||||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Installation, Bootstrap, Kern-Anwendungsfall mit Bildern; Bedrohungsanalyse, Backup-Konzept.
|
Installation, Bootstrap, Kern-Anwendungsfall mit Bildern; Bedrohungsanalyse, Backup-Konzept.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Handbuch mit Bildern (`docs/anleitung/README.md`, 12 Kapitel)
|
||||||
|
- [x] Installation, authentik, Reverse Proxy (`docs/betrieb/installation.md`)
|
||||||
|
- [x] Bedrohungsanalyse und Notfallplan (`docs/betrieb/sicherheit.md`)
|
||||||
|
- [x] Backup-Konzept mit Skripten und geprobter Wiederherstellung (`docs/betrieb/sicherung.md`)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] Handbuch mit Bildern (`docs/anleitung/README.md`, 12 Kapitel)
|
||||||
|
- [x] Installation, authentik, Reverse Proxy (`docs/betrieb/installation.md`)
|
||||||
|
- [x] Bedrohungsanalyse und Notfallplan (`docs/betrieb/sicherheit.md`)
|
||||||
|
- [x] Backup-Konzept mit Skripten und geprobter Wiederherstellung (`docs/betrieb/sicherung.md`)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
–
|
–
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Wiederherstellung geprobt** auf einem Wegwerf-Stapel – dabei zwei Fehler gefunden und behoben (Schema pgboss mit `--clean` nicht rückspielbar; Abbruch ohne Transaktion löschte es halb). Jetzt: nur Schema `public`, eine Transaktion, Warteschlange neu, offene Vorgänge abgebrochen.
|
||||||
|
- **Master-Key wird nicht mitgesichert**, nur ein Prüfwert (SHA-256). Das Wiederherstellungsskript bricht vor jeder Änderung ab, wenn der bereitliegende Master-Key nicht passt; der Worker startet nicht mit einem falschen Master-Key.
|
||||||
|
- **Notfallplan „sshkv kompromittiert“** beschreibt das Entfernen des Deployment-Keys auf allen Hosts von Hand – eine eingebaute Rotation des Deployment-Keys gibt es noch nicht.
|
||||||
|
- Bilder entstehen aus `scripts/screenshots.ts` und den E2E-Abläufen.
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
# #030 Issues nach Gitea übertragen
|
# #030 Issues nach Gitea übertragen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.1.0
|
**Meilenstein:** v0.1.0
|
||||||
**Labels:** chore
|
**Labels:** chore
|
||||||
**Geschätzt:** 1 h **Tatsächlich:** –
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 10 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Issue-Dateien, Labels und Meilensteine in Gitea anlegen (wartet auf Zugangsdaten).
|
Issue-Dateien, Labels und Meilensteine in Gitea anlegen (wartet auf Zugangsdaten).
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] `scripts/gitea-issues.ts`
|
- [x] `scripts/gitea-issues.ts`
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] wird zu Beginn der Phase verfeinert
|
- [x] wird zu Beginn der Phase verfeinert
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
–
|
–
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- Repo `florian.wilde/sshkeyvault` auf git.wilde-server.de (von Florian angelegt), `main` und Tags v0.1.0–v0.5.0 gepusht.
|
||||||
|
- `pnpm issues:sync` liest Adresse und Repo aus dem Git-Remote und den Token aus den Git-Zugangsdaten; die Issue-Dateien bleiben die Quelle der Wahrheit.
|
||||||
|
|||||||
@@ -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,22 @@
|
|||||||
|
# #032 Nacharbeiten aus Phase 4/5
|
||||||
|
**Status:** offen
|
||||||
|
**Meilenstein:** v1.0.0
|
||||||
|
**Labels:** test, chore
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [ ] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC)
|
||||||
|
- [ ] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv)
|
||||||
|
- [ ] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`)
|
||||||
|
- [ ] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker)
|
||||||
|
|
||||||
|
## Akzeptanzkriterien
|
||||||
|
- [ ] Alle Suiten grün
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
#023, #024, #025, #027
|
||||||
|
|
||||||
|
## Notizen / Entscheidungen
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sshkv",
|
"name": "sshkv",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "SSHKeyVault – zentrale Verwaltung von SSH-Schluesseln mit Weboberflaeche",
|
"description": "SSHKeyVault – zentrale Verwaltung von SSH-Schluesseln mit Weboberflaeche",
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"jose": "^6.2.12",
|
"jose": "^6.2.12",
|
||||||
"lucide-react": "1.47.0",
|
"lucide-react": "1.47.0",
|
||||||
"next": "16.3.6",
|
"next": "16.3.6",
|
||||||
|
"nodemailer": "10.0.10",
|
||||||
"openid-client": "^6.8.8",
|
"openid-client": "^6.8.8",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
"pg-boss": "^12.37.1",
|
"pg-boss": "^12.37.1",
|
||||||
@@ -71,6 +72,7 @@
|
|||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@types/node": "^24.19.2",
|
"@types/node": "^24.19.2",
|
||||||
|
"@types/nodemailer": "8.0.2",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "^19.3.0",
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.3.6
|
specifier: 16.3.6
|
||||||
version: 16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
version: 16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||||
|
nodemailer:
|
||||||
|
specifier: 10.0.10
|
||||||
|
version: 10.0.10
|
||||||
openid-client:
|
openid-client:
|
||||||
specifier: ^6.8.8
|
specifier: ^6.8.8
|
||||||
version: 6.8.8
|
version: 6.8.8
|
||||||
@@ -239,6 +242,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.19.2
|
specifier: ^24.19.2
|
||||||
version: 24.19.2
|
version: 24.19.2
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: 8.0.2
|
||||||
|
version: 8.0.2
|
||||||
'@types/pg':
|
'@types/pg':
|
||||||
specifier: ^8.23.1
|
specifier: ^8.23.1
|
||||||
version: 8.23.1
|
version: 8.23.1
|
||||||
@@ -1481,6 +1487,9 @@ packages:
|
|||||||
'@types/node@26.6.5':
|
'@types/node@26.6.5':
|
||||||
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
|
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.2':
|
||||||
|
resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==}
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||||
|
|
||||||
@@ -3067,6 +3076,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
|
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
nodemailer@10.0.10:
|
||||||
|
resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
non-error@0.1.0:
|
non-error@0.1.0:
|
||||||
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -4910,6 +4923,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 8.9.0
|
undici-types: 8.9.0
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.2':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.19.2
|
||||||
|
|
||||||
'@types/pg@8.23.1':
|
'@types/pg@8.23.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 26.6.5
|
'@types/node': 26.6.5
|
||||||
@@ -6625,6 +6642,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.58: {}
|
node-releases@2.0.58: {}
|
||||||
|
|
||||||
|
nodemailer@10.0.10: {}
|
||||||
|
|
||||||
non-error@0.1.0: {}
|
non-error@0.1.0: {}
|
||||||
|
|
||||||
oauth4webapi@3.8.8: {}
|
oauth4webapi@3.8.8: {}
|
||||||
|
|||||||
@@ -0,0 +1,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");
|
||||||
@@ -114,6 +114,8 @@ enum VorgangsArt {
|
|||||||
ZIELKONTO_ANLEGEN
|
ZIELKONTO_ANLEGEN
|
||||||
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
|
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
|
||||||
ZIELKONTO_ENTFERNEN
|
ZIELKONTO_ENTFERNEN
|
||||||
|
/// Testnachricht ueber einen Benachrichtigungskanal (#028).
|
||||||
|
BENACHRICHTIGUNG_TEST
|
||||||
}
|
}
|
||||||
|
|
||||||
enum VorgangsStatus {
|
enum VorgangsStatus {
|
||||||
@@ -125,6 +127,17 @@ enum VorgangsStatus {
|
|||||||
ABGEBROCHEN
|
ABGEBROCHEN
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Versandweg einer Benachrichtigung (#028).
|
||||||
|
enum MeldungsKanal {
|
||||||
|
EMAIL
|
||||||
|
WEBHOOK
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ZustellErgebnis {
|
||||||
|
GESENDET
|
||||||
|
FEHLGESCHLAGEN
|
||||||
|
}
|
||||||
|
|
||||||
enum ProtokollErgebnis {
|
enum ProtokollErgebnis {
|
||||||
ERFOLG
|
ERFOLG
|
||||||
FEHLER
|
FEHLER
|
||||||
@@ -496,6 +509,30 @@ model Einstellung {
|
|||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Versandprotokoll der Benachrichtigungen (#028): je Anlass und Kanal hoechstens eine Zeile.
|
||||||
|
/// Vor jedem Versand wird hiergegen geprueft – so meldet sshkv dieselbe Abweichung oder dieselbe
|
||||||
|
/// Ablaufstufe nie zweimal, auch wenn ein Zeitplan doppelt feuert oder der Worker neu startet.
|
||||||
|
model Zustellung {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// Fingerabdruck des Anlasses, z. B. drift:<abweichungId>, ablauf:<schluesselId>:7:<datum>.
|
||||||
|
anlass String
|
||||||
|
kanal MeldungsKanal
|
||||||
|
ergebnis ZustellErgebnis
|
||||||
|
/// Kurzer Fehlertext ohne Zugangsdaten.
|
||||||
|
fehler String?
|
||||||
|
versuche Int @default(1)
|
||||||
|
/// Wann ein Fehlschlag fruehestens erneut versucht wird; leer = nicht mehr.
|
||||||
|
naechsterVersuchAm DateTime?
|
||||||
|
/// Gemeinsame Kennung aller Anlaesse einer Nachricht – die Oberflaeche zeigt je Nachricht eine Zeile.
|
||||||
|
sendung String
|
||||||
|
betreff String
|
||||||
|
zeitpunkt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([anlass, kanal])
|
||||||
|
@@index([zeitpunkt])
|
||||||
|
@@index([sendung])
|
||||||
|
}
|
||||||
|
|
||||||
/// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster).
|
/// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster).
|
||||||
model Ratenbegrenzung {
|
model Ratenbegrenzung {
|
||||||
schluessel String @id
|
schluessel String @id
|
||||||
|
|||||||
@@ -76,14 +76,15 @@ class Gitea {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly basis: string,
|
private readonly basis: string,
|
||||||
private readonly repo: string,
|
private readonly repo: string,
|
||||||
private readonly token: string,
|
/** Fertiger Wert fuer den Authorization-Header (`token …` oder `Basic …`). */
|
||||||
|
private readonly autorisierung: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async anfrage<T>(pfad: string, optionen: RequestInit = {}): Promise<T> {
|
private async anfrage<T>(pfad: string, optionen: RequestInit = {}): Promise<T> {
|
||||||
const antwort = await fetch(`${this.basis}/api/v1/repos/${this.repo}${pfad}`, {
|
const antwort = await fetch(`${this.basis}/api/v1/repos/${this.repo}${pfad}`, {
|
||||||
...optionen,
|
...optionen,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `token ${this.token}`,
|
Authorization: this.autorisierung,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...optionen.headers,
|
...optionen.headers,
|
||||||
},
|
},
|
||||||
@@ -149,10 +150,12 @@ function ausRemote(): { basis: string; repo: string } | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Holt den Token aus den Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`).
|
* Holt die Zugangsdaten aus Git (Schlüsselbund oder `~/.git-credentials`), damit sie nicht
|
||||||
* So genügt ein einziger Token für Push und API – er muss nicht zusätzlich in der .env stehen.
|
* 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 tokenAusGitCredentials(basis: string): string | null {
|
function zugangAusGitCredentials(basis: string): { benutzer: string; geheimnis: string } | null {
|
||||||
const host = new URL(basis).host;
|
const host = new URL(basis).host;
|
||||||
|
|
||||||
// Erst die eingerichteten Helfer (z. B. Schlüsselbund), dann zusätzlich die Datei
|
// Erst die eingerichteten Helfer (z. B. Schlüsselbund), dann zusätzlich die Datei
|
||||||
@@ -167,7 +170,8 @@ function tokenAusGitCredentials(basis: string): string | null {
|
|||||||
stdio: ["pipe", "pipe", "ignore"],
|
stdio: ["pipe", "pipe", "ignore"],
|
||||||
});
|
});
|
||||||
const passwort = /^password=(.*)$/m.exec(antwort);
|
const passwort = /^password=(.*)$/m.exec(antwort);
|
||||||
if (passwort?.[1]) return passwort[1];
|
const benutzer = /^username=(.*)$/m.exec(antwort);
|
||||||
|
if (passwort?.[1]) return { benutzer: benutzer?.[1] ?? "", geheimnis: passwort[1] };
|
||||||
} catch {
|
} catch {
|
||||||
// Nächste Möglichkeit versuchen.
|
// Nächste Möglichkeit versuchen.
|
||||||
}
|
}
|
||||||
@@ -186,8 +190,14 @@ async function main() {
|
|||||||
const remote = ausRemote();
|
const remote = ausRemote();
|
||||||
const basis = (process.env.GITEA_URL ?? remote?.basis ?? "").replace(/\/$/, "");
|
const basis = (process.env.GITEA_URL ?? remote?.basis ?? "").replace(/\/$/, "");
|
||||||
const repo = process.env.GITEA_REPO ?? remote?.repo ?? "";
|
const repo = process.env.GITEA_REPO ?? remote?.repo ?? "";
|
||||||
const token = process.env.GITEA_TOKEN || (basis ? (tokenAusGitCredentials(basis) ?? "") : "");
|
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";
|
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 verzeichnis = path.join(process.cwd(), "docs", "issues");
|
||||||
const issues = await dateienLesen(verzeichnis);
|
const issues = await dateienLesen(verzeichnis);
|
||||||
@@ -215,7 +225,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Ziel: ${basis}/${repo} (Token aus: ${tokenQuelle})`);
|
console.log(`Ziel: ${basis}/${repo} (Token aus: ${tokenQuelle})`);
|
||||||
const gitea = new Gitea(basis, repo, token);
|
const gitea = new Gitea(basis, repo, autorisierung);
|
||||||
|
|
||||||
const vorhandeneLabels = new Map((await gitea.labels()).map((l) => [l.name.toLowerCase(), l.id]));
|
const vorhandeneLabels = new Map((await gitea.labels()).map((l) => [l.name.toLowerCase(), l.id]));
|
||||||
const benoetigteLabels = [...new Set(issues.flatMap((issue) => issue.labels))];
|
const benoetigteLabels = [...new Set(issues.flatMap((issue) => issue.labels))];
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
|
|||||||
pfad: "/vorlagen",
|
pfad: "/vorlagen",
|
||||||
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
|
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
|
||||||
},
|
},
|
||||||
|
// Audit-Log (#027): unter md Karten statt Tabelle, die Seite darf nicht seitwaerts scrollen.
|
||||||
|
{ name: "protokoll", pfad: "/protokoll" },
|
||||||
|
// Benachrichtigungen, Zeitplaene, Instanz (#028) – als Admin mit Formular und Testknoepfen.
|
||||||
|
{ name: "einstellungen", pfad: "/einstellungen" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ANSICHTEN = [
|
const ANSICHTEN = [
|
||||||
|
|||||||
@@ -0,0 +1,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 type { Metadata } from "next";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular";
|
||||||
|
import { Badge } from "@/components/ui/Badge";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||||
|
import { datumZeit } from "@/lib/format";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { einstellungenSeite, type Zeitplan, type ZustellungZeile } from "@/server/queries/einstellungen";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Einstellungen" };
|
export const metadata: Metadata = { title: "Einstellungen" };
|
||||||
|
|
||||||
export default async function Einstellungen() {
|
function Info({ titel, children }: { titel: string; children: ReactNode }) {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
|
||||||
await sitzungErforderlich();
|
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite
|
<div className="min-w-0">
|
||||||
titel="Einstellungen"
|
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||||
untertitel="Deployment-Key, eigene Vorlagen, Benachrichtigungen"
|
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
|
||||||
phase={3}
|
</div>
|
||||||
issue="#019"
|
);
|
||||||
/>
|
}
|
||||||
|
|
||||||
|
function Abschnitt({ titel, id, children }: { titel: string; id: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section aria-labelledby={id} className="flex min-w-0 flex-col gap-4">
|
||||||
|
<h2 id={id} className="text-muted text-[12.5px] font-semibold tracking-[0.08em] uppercase">
|
||||||
|
{titel}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZeitplanAnzeige({ plan }: { plan: Zeitplan }) {
|
||||||
|
return (
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
{plan.beschreibung && <span>{plan.beschreibung}</span>}
|
||||||
|
<code className="text-muted font-mono text-[13px]">{plan.ausdruck}</code>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KANAL_TEXT = { EMAIL: "E-Mail", WEBHOOK: "Webhook" } as const;
|
||||||
|
|
||||||
|
function Zustellungen({ zeilen }: { zeilen: readonly ZustellungZeile[] }) {
|
||||||
|
if (zeilen.length === 0) {
|
||||||
|
return (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Inbox className="size-5" />}
|
||||||
|
titel="Noch nichts versendet"
|
||||||
|
text="Hier erscheinen die letzten Zustellungen samt Ergebnis – auch Testnachrichten."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Tabelle
|
||||||
|
beschriftung="Letzte Zustellungen"
|
||||||
|
spalten={[{ titel: "Zeitpunkt" }, { titel: "Kanal" }, { titel: "Nachricht" }, { titel: "Ergebnis" }]}
|
||||||
|
>
|
||||||
|
{zeilen.map((z) => (
|
||||||
|
<TabellenZeile key={z.sendung}>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<time dateTime={z.zeitpunkt.toISOString()}>{datumZeit(z.zeitpunkt)}</time>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">{KANAL_TEXT[z.kanal]}</TabellenZelle>
|
||||||
|
<TabellenZelle className="min-w-[16rem]">
|
||||||
|
<span className="block">{z.betreff}</span>
|
||||||
|
<span className="text-muted block text-[12.5px]">
|
||||||
|
{z.anlaesse === 1 ? "1 Anlass" : `${z.anlaesse} Anlässe`}
|
||||||
|
{z.versuche > 1 && ` · ${z.versuche}. Versuch`}
|
||||||
|
</span>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="min-w-[12rem]">
|
||||||
|
{z.ergebnis === "GESENDET" ? (
|
||||||
|
<Badge variante="ok">gesendet</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="flex flex-col items-start gap-1">
|
||||||
|
<Badge variante="gefahr">fehlgeschlagen</Badge>
|
||||||
|
{z.fehler && <span className="text-danger text-[12.5px] break-words">{z.fehler}</span>}
|
||||||
|
<span className="text-muted text-[12.5px]">
|
||||||
|
{z.naechsterVersuchAm
|
||||||
|
? `neuer Versuch ab ${datumZeit(z.naechsterVersuchAm)}`
|
||||||
|
: "kein weiterer Versuch"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einstellungen (#028): lesen duerfen alle, aendern nur `einstellungenVerwalten` (Admin). */
|
||||||
|
export default async function Einstellungen() {
|
||||||
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
|
const { benutzer } = await sitzungErforderlich("lesen", "/einstellungen");
|
||||||
|
const darfAendern = darf(benutzer.rolle, "einstellungenVerwalten");
|
||||||
|
const seite = await einstellungenSeite();
|
||||||
|
// Eine Webhook-Adresse traegt oft ein Token im Pfad – Leser sehen nur Schema und Host.
|
||||||
|
const ansicht = darfAendern
|
||||||
|
? seite.benachrichtigung
|
||||||
|
: {
|
||||||
|
...seite.benachrichtigung,
|
||||||
|
webhook: { ...seite.benachrichtigung.webhook, url: seite.webhookZiel ?? "" },
|
||||||
|
};
|
||||||
|
const { instanz } = seite;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
|
titel="Einstellungen"
|
||||||
|
untertitel={
|
||||||
|
darfAendern
|
||||||
|
? "Benachrichtigungen, Zeitpläne und Angaben zu dieser Instanz"
|
||||||
|
: "Nur Ansicht – ändern dürfen Administratoren."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid min-w-0 gap-6 sm:gap-7 xl:grid-cols-[minmax(0,1fr)_22rem] xl:items-start">
|
||||||
|
<Abschnitt titel="Benachrichtigungen" id="abschnitt-benachrichtigungen">
|
||||||
|
<BenachrichtigungFormular ansicht={ansicht} darfAendern={darfAendern} />
|
||||||
|
<Karte
|
||||||
|
titel="Letzte Zustellungen"
|
||||||
|
untertitel="Fehlgeschlagene Zustellungen wiederholt der Worker bis zu fünfmal."
|
||||||
|
>
|
||||||
|
<Zustellungen zeilen={seite.zustellungen} />
|
||||||
|
</Karte>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||||
|
<Abschnitt titel="Abgleich" id="abschnitt-abgleich">
|
||||||
|
<Karte as="div">
|
||||||
|
<dl className="grid gap-4">
|
||||||
|
<Info titel="Abgleich aller Hosts">
|
||||||
|
<ZeitplanAnzeige plan={seite.abgleich} />
|
||||||
|
</Info>
|
||||||
|
<Info titel="Tägliche Zusammenfassung">
|
||||||
|
<ZeitplanAnzeige plan={seite.meldung} />
|
||||||
|
</Info>
|
||||||
|
<Info titel="Geänderte Host-Keys">alle 5 Minuten geprüft</Info>
|
||||||
|
</dl>
|
||||||
|
<p className="text-muted mt-4 text-[13px]">
|
||||||
|
Zeitzone Europe/Berlin. Gesetzt über <code className="font-mono">SSHKV_SYNC_CRON</code> und{" "}
|
||||||
|
<code className="font-mono">SSHKV_MELDUNG_CRON</code> in der Umgebung des Workers.
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
</Abschnitt>
|
||||||
|
|
||||||
|
{/* Platz fuer den Abschnitt „Syslog“ – folgt in einem eigenen Issue. */}
|
||||||
|
|
||||||
|
<Abschnitt titel="Instanz" id="abschnitt-instanz">
|
||||||
|
<Karte as="div">
|
||||||
|
<dl className="grid gap-4">
|
||||||
|
<Info titel="Version">
|
||||||
|
<span className="font-mono text-[13.5px]">{instanz.version}</span>
|
||||||
|
</Info>
|
||||||
|
<Info titel="Deployment-Key">
|
||||||
|
{instanz.deploymentKey ? (
|
||||||
|
<>
|
||||||
|
<MonoKopieren
|
||||||
|
wert={instanz.deploymentKey.fingerprint}
|
||||||
|
beschriftung="Fingerprint des Deployment-Keys"
|
||||||
|
umbrechen
|
||||||
|
/>
|
||||||
|
{darf(benutzer.rolle, "deploymentKeyVerwalten") && (
|
||||||
|
<Link
|
||||||
|
href="/deployment-key"
|
||||||
|
className="text-accent mt-1 inline-flex min-h-11 items-center text-[14px] font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Deployment-Key rotieren
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-accent">noch nicht erzeugt – läuft der Worker?</span>
|
||||||
|
)}
|
||||||
|
</Info>
|
||||||
|
<Info titel="Anmeldung (OIDC)">
|
||||||
|
{instanz.oidc.konfiguriert ? (
|
||||||
|
instanz.oidc.name
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">nicht eingerichtet</span>
|
||||||
|
)}
|
||||||
|
</Info>
|
||||||
|
<Info titel="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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import { ArrowRight, Plus, Route } from "lucide-react";
|
import { ArrowRight, Plus, Route } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
|
||||||
abweichungen,
|
|
||||||
BEISPIEL_JETZT,
|
|
||||||
baldAblaufend,
|
|
||||||
kennzahlen,
|
|
||||||
letzteAktionen,
|
|
||||||
letzterAbgleich,
|
|
||||||
type AktionsArt,
|
|
||||||
} from "@/beispiel/uebersicht";
|
|
||||||
import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
|
import { AlleAbgleichenKnopf } from "@/components/abgleich/AlleAbgleichenKnopf";
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
@@ -19,7 +10,10 @@ import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
|||||||
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
import { Statuspunkt } from "@/components/ui/Statuspunkt";
|
||||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||||
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
|
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { uebersicht, type AktionsArt } from "@/server/queries/uebersicht";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Übersicht" };
|
export const metadata: Metadata = { title: "Übersicht" };
|
||||||
|
|
||||||
@@ -29,36 +23,45 @@ const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
|
|||||||
erstellt: { text: "Erstellt", farbe: "text-text" },
|
erstellt: { text: "Erstellt", farbe: "text-text" },
|
||||||
abgleich: { text: "Abgleich", farbe: "text-info" },
|
abgleich: { text: "Abgleich", farbe: "text-info" },
|
||||||
rotiert: { text: "Rotiert", farbe: "text-accent" },
|
rotiert: { text: "Rotiert", farbe: "text-accent" },
|
||||||
|
anmeldung: { text: "Anmeldung", farbe: "text-muted" },
|
||||||
|
verweigert: { text: "Verweigert", farbe: "text-danger" },
|
||||||
|
sonstiges: { text: "Aktion", farbe: "text-text" },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Übersicht (#026): Aufbau aus dem Prototyp (#002), Zahlen aus der Datenbank. */
|
||||||
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
|
|
||||||
* folgt in #026; Aufbau und Komponenten bleiben.
|
|
||||||
*/
|
|
||||||
export default async function Uebersicht() {
|
export default async function Uebersicht() {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
await sitzungErforderlich();
|
const { benutzer } = await sitzungErforderlich();
|
||||||
|
const jetzt = new Date();
|
||||||
|
const { letzterAbgleich, kennzahlen, mitAbweichung, weitereAbweichungen, baldAblaufend, letzteAktionen } =
|
||||||
|
await uebersicht(jetzt);
|
||||||
|
const verwalten = darf(benutzer.rolle, "hostsVerwalten");
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 sm:gap-7">
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
<Seitenkopf
|
<Seitenkopf
|
||||||
titel="Übersicht"
|
titel="Übersicht"
|
||||||
badges={<Badge variante="messing">Prototyp · Beispieldaten</Badge>}
|
|
||||||
untertitel={
|
untertitel={
|
||||||
<>
|
letzterAbgleich ? (
|
||||||
Letzter Abgleich {relativ(letzterAbgleich, BEISPIEL_JETZT)} ·{" "}
|
<>
|
||||||
<time dateTime={letzterAbgleich} className="font-mono text-[13px]">
|
Letzter Abgleich {relativ(letzterAbgleich, jetzt)} ·{" "}
|
||||||
{datumZeit(letzterAbgleich)}
|
<time dateTime={letzterAbgleich.toISOString()} className="font-mono text-[13px]">
|
||||||
</time>
|
{datumZeit(letzterAbgleich)}
|
||||||
</>
|
</time>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Noch kein Abgleich gelaufen."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
aktionen={
|
aktionen={
|
||||||
<>
|
<>
|
||||||
<AlleAbgleichenKnopf />
|
{verwalten && <AlleAbgleichenKnopf />}
|
||||||
<Button href="/assistent" icon={<Route className="size-4" />}>
|
{darf(benutzer.rolle, "privateKeyAusrollen") && (
|
||||||
Host-zu-Host-Assistent
|
<Button href="/assistent" icon={<Route className="size-4" />}>
|
||||||
</Button>
|
Host-zu-Host-Assistent
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
href="/schluessel"
|
href="/schluessel/neu"
|
||||||
variante="primaer"
|
variante="primaer"
|
||||||
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
icon={<Plus className="size-4" strokeWidth={2.4} />}
|
||||||
>
|
>
|
||||||
@@ -98,46 +101,60 @@ export default async function Uebersicht() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
|
||||||
<Karte titel="Hosts mit Abweichung" untertitel="Ergebnis des letzten Abgleichs">
|
<Karte
|
||||||
<Tabelle
|
titel="Hosts mit Abweichung"
|
||||||
beschriftung="Hosts mit Abweichung"
|
untertitel={
|
||||||
spalten={[
|
weitereAbweichungen
|
||||||
{ titel: "Host" },
|
? `Ergebnis des letzten Abgleichs · ${weitereAbweichungen} weitere`
|
||||||
{ titel: "Benutzer" },
|
: "Ergebnis des letzten Abgleichs"
|
||||||
{ titel: "Befund" },
|
}
|
||||||
{ titel: "Aktion", rechts: true, versteckt: true },
|
>
|
||||||
]}
|
{mitAbweichung.length === 0 ? (
|
||||||
>
|
<p className="text-ok text-[14px]">● Alle Hosts synchron – keine offenen Abweichungen.</p>
|
||||||
{abweichungen.map((a) => (
|
) : (
|
||||||
<TabellenZeile key={`${a.host}-${a.benutzer}`}>
|
<Tabelle
|
||||||
<TabellenZelle mono className="whitespace-nowrap">
|
beschriftung="Hosts mit Abweichung"
|
||||||
{a.host}
|
spalten={[
|
||||||
</TabellenZelle>
|
{ titel: "Host" },
|
||||||
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
|
{ titel: "Benutzer" },
|
||||||
<TabellenZelle className="whitespace-nowrap">
|
{ titel: "Befund" },
|
||||||
<Statuspunkt status={a.status} text={a.befund} />
|
{ titel: "Aktion", rechts: true, versteckt: true },
|
||||||
</TabellenZelle>
|
]}
|
||||||
<TabellenZelle rechts>
|
>
|
||||||
<Link
|
{mitAbweichung.map((a) => (
|
||||||
href="/hosts"
|
<TabellenZeile key={`${a.hostId}-${a.benutzer}-${a.status}`}>
|
||||||
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
|
<TabellenZelle mono className="whitespace-nowrap">
|
||||||
>
|
{a.host}
|
||||||
Prüfen<span className="sr-only"> – {a.host}</span>
|
</TabellenZelle>
|
||||||
</Link>
|
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
|
||||||
</TabellenZelle>
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
</TabellenZeile>
|
<Statuspunkt status={a.status} text={a.befund} />
|
||||||
))}
|
</TabellenZelle>
|
||||||
</Tabelle>
|
<TabellenZelle rechts>
|
||||||
|
<Link
|
||||||
|
href={`/hosts/${a.hostId}`}
|
||||||
|
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Prüfen<span className="sr-only"> – {a.host}</span>
|
||||||
|
</Link>
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
))}
|
||||||
|
</Tabelle>
|
||||||
|
)}
|
||||||
</Karte>
|
</Karte>
|
||||||
|
|
||||||
<Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen">
|
<Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen">
|
||||||
|
{baldAblaufend.length === 0 && (
|
||||||
|
<p className="text-muted text-[14px]">Kein Schlüssel läuft in den nächsten 30 Tagen ab.</p>
|
||||||
|
)}
|
||||||
<ul className="flex flex-col">
|
<ul className="flex flex-col">
|
||||||
{baldAblaufend.map((s) => {
|
{baldAblaufend.map((s) => {
|
||||||
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
|
const tage = tageBis(s.ablauf, jetzt);
|
||||||
return (
|
return (
|
||||||
<li key={s.zweck} className="border-border border-b last:border-b-0">
|
<li key={s.id} className="border-border border-b last:border-b-0">
|
||||||
<Link
|
<Link
|
||||||
href="/schluessel"
|
href={`/schluessel/${s.id}`}
|
||||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
@@ -146,8 +163,16 @@ export default async function Uebersicht() {
|
|||||||
{s.person} · <span className="font-mono">{s.typ}</span>
|
{s.person} · <span className="font-mono">{s.typ}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Badge variante="messing" className="shrink-0">
|
<Badge variante={tage < 0 ? "gefahr" : "messing"} className="shrink-0">
|
||||||
<time dateTime={s.ablauf}>{tage === 1 ? "morgen" : `in ${tage} Tagen`}</time>
|
<time dateTime={s.ablauf.toISOString()}>
|
||||||
|
{tage < 0
|
||||||
|
? `abgelaufen${s.ausgerollt ? " · noch ausgerollt" : ""}`
|
||||||
|
: tage === 0
|
||||||
|
? "heute"
|
||||||
|
: tage === 1
|
||||||
|
? "morgen"
|
||||||
|
: `in ${tage} Tagen`}
|
||||||
|
</time>
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
@@ -168,13 +193,17 @@ export default async function Uebersicht() {
|
|||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{letzteAktionen.length === 0 && <Leerzustand titel="Noch keine Aktionen" />}
|
||||||
<ol className="flex flex-col">
|
<ol className="flex flex-col">
|
||||||
{letzteAktionen.map((a) => (
|
{letzteAktionen.map((a) => (
|
||||||
<li
|
<li
|
||||||
key={a.zeit}
|
key={a.id}
|
||||||
className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline"
|
className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline"
|
||||||
>
|
>
|
||||||
<time dateTime={a.zeit} className="text-muted font-mono text-[12.5px] whitespace-nowrap">
|
<time
|
||||||
|
dateTime={a.zeit.toISOString()}
|
||||||
|
className="text-muted font-mono text-[12.5px] whitespace-nowrap"
|
||||||
|
>
|
||||||
{datum(a.zeit)} {uhrzeit(a.zeit)}
|
{datum(a.zeit)} {uhrzeit(a.zeit)}
|
||||||
</time>
|
</time>
|
||||||
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>
|
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>
|
||||||
|
|||||||
@@ -1,18 +1,202 @@
|
|||||||
|
import { Download, ScrollText, Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
import { KettenPruefung } from "@/components/protokoll/KettenPruefung";
|
||||||
|
import { ProtokollListe } from "@/components/protokoll/ProtokollListe";
|
||||||
|
import { Button, buttonKlassen } from "@/components/ui/Button";
|
||||||
|
import { Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||||
|
import { Karte } from "@/components/ui/Karte";
|
||||||
|
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||||
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
|
import { AKTIONSGRUPPEN } from "@/lib/protokoll-aktionen";
|
||||||
|
import {
|
||||||
|
cursorAus,
|
||||||
|
ERGEBNIS_TEXT,
|
||||||
|
protokollFilterAktiv,
|
||||||
|
protokollFilterAus,
|
||||||
|
protokollFilterQuery,
|
||||||
|
} from "@/lib/protokoll-filter";
|
||||||
|
import { syslogZielText } from "@/lib/syslog";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { protokollAkteure, protokollSeite, protokollZaehlen } from "@/server/queries/protokoll";
|
||||||
|
import { ketteZwischengespeichert, syslogKonfig } from "@/server/services/protokoll";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Audit-Log" };
|
export const metadata: Metadata = { title: "Audit-Log" };
|
||||||
|
|
||||||
export default async function Protokoll() {
|
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-Log (#027): jede Rolle darf lesen. Filter per GET, damit Links, „ältere laden“ und
|
||||||
|
* der CSV-Export denselben Ausschnitt meinen. Es gibt hier bewusst keinen Knopf, der einen
|
||||||
|
* Eintrag aendert oder loescht.
|
||||||
|
*/
|
||||||
|
export default async function Protokoll({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||||
await sitzungErforderlich();
|
await sitzungErforderlich("lesen", "/protokoll");
|
||||||
|
const parameter = await searchParams;
|
||||||
|
const filter = protokollFilterAus(parameter);
|
||||||
|
const vor = cursorAus(parameter["vor"]);
|
||||||
|
const [seite, zaehler, akteure] = await Promise.all([
|
||||||
|
protokollSeite(filter, vor),
|
||||||
|
protokollZaehlen(filter),
|
||||||
|
protokollAkteure(),
|
||||||
|
]);
|
||||||
|
const gefiltert = protokollFilterAktiv(filter);
|
||||||
|
const query = protokollFilterQuery(filter);
|
||||||
|
const kette = ketteZwischengespeichert();
|
||||||
|
const syslog = syslogKonfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Platzhalterseite
|
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||||
titel="Audit-Log"
|
<Seitenkopf
|
||||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
|
titel="Audit-Log"
|
||||||
phase={5}
|
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen – nur lesbar, nicht änderbar"
|
||||||
issue="#027"
|
aktionen={
|
||||||
/>
|
<a href={`/api/protokoll.csv${query}`} download className={buttonKlassen("sekundaer")}>
|
||||||
|
<Download className="size-4" aria-hidden />
|
||||||
|
CSV exportieren
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Karte innen="ohne" className="divide-border divide-y">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 px-5 py-4 sm:px-6">
|
||||||
|
<p className="text-[14px]">
|
||||||
|
<span className="font-display text-text text-[22px] font-semibold tabular-nums">
|
||||||
|
{zaehler.gesamt.toLocaleString("de-DE")}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-muted">Einträge insgesamt</span>
|
||||||
|
</p>
|
||||||
|
{gefiltert && (
|
||||||
|
<p className="text-muted text-[14px]">
|
||||||
|
<span className="text-text font-medium tabular-nums">
|
||||||
|
{zaehler.treffer.toLocaleString("de-DE")}
|
||||||
|
</span>{" "}
|
||||||
|
passen zum Filter
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-muted text-[13px] sm:ml-auto">
|
||||||
|
Syslog:{" "}
|
||||||
|
{syslog ? (
|
||||||
|
<span className="text-text font-mono">{syslogZielText(syslog)}</span>
|
||||||
|
) : (
|
||||||
|
<span>nicht eingerichtet</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-4 sm:px-6">
|
||||||
|
<KettenPruefung
|
||||||
|
anfang={
|
||||||
|
kette
|
||||||
|
? {
|
||||||
|
fehlerhafteId: kette.fehlerhafteId,
|
||||||
|
eintraege: kette.eintraege,
|
||||||
|
geprueftAm: kette.geprueftAm.toISOString(),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Karte>
|
||||||
|
|
||||||
|
<form method="get" role="search" className="grid grid-cols-2 items-end gap-3 lg:grid-cols-4">
|
||||||
|
<Feld
|
||||||
|
beschriftung="Suche"
|
||||||
|
name="q"
|
||||||
|
type="search"
|
||||||
|
defaultValue={filter.q}
|
||||||
|
placeholder="Fingerprint, Host oder Benutzer"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="col-span-2"
|
||||||
|
/>
|
||||||
|
<Feld
|
||||||
|
beschriftung="Host"
|
||||||
|
name="host"
|
||||||
|
defaultValue={filter.host}
|
||||||
|
placeholder="z. B. srv-pve01"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
mono
|
||||||
|
className="col-span-2 sm:col-span-1"
|
||||||
|
/>
|
||||||
|
<Auswahlliste
|
||||||
|
beschriftung="Akteur"
|
||||||
|
name="akteur"
|
||||||
|
defaultValue={filter.akteur}
|
||||||
|
className="col-span-2 sm:col-span-1"
|
||||||
|
>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{/* Ein gefilterter Name, der nicht mehr unter den haeufigsten ist, bleibt waehlbar. */}
|
||||||
|
{[...new Set([...akteure, ...(filter.akteur ? [filter.akteur] : [])])].map((name) => (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Aktion" name="gruppe" defaultValue={filter.gruppe}>
|
||||||
|
<option value="">Alle Aktionen</option>
|
||||||
|
{Object.entries(AKTIONSGRUPPEN).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Auswahlliste beschriftung="Ergebnis" name="ergebnis" defaultValue={filter.ergebnis}>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
{Object.entries(ERGEBNIS_TEXT).map(([wert, text]) => (
|
||||||
|
<option key={wert} value={wert}>
|
||||||
|
{text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Auswahlliste>
|
||||||
|
<Feld beschriftung="Von" name="von" type="date" defaultValue={filter.von} />
|
||||||
|
<Feld beschriftung="Bis" name="bis" type="date" defaultValue={filter.bis} />
|
||||||
|
<div className="col-span-2 flex gap-2.5 sm:justify-end lg:col-span-4">
|
||||||
|
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||||
|
Filtern
|
||||||
|
</Button>
|
||||||
|
{gefiltert && (
|
||||||
|
<Button href="/protokoll" className="flex-1 sm:flex-none">
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{vor !== undefined && (
|
||||||
|
<p className="text-muted text-[14px]">
|
||||||
|
Einträge älter als #{String(vor)}.{" "}
|
||||||
|
<a href={`/protokoll${query}`} className="text-accent underline-offset-2 hover:underline">
|
||||||
|
Zu den neuesten
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{seite.zeilen.length === 0 ? (
|
||||||
|
gefiltert ? (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<Search className="size-5" />}
|
||||||
|
titel="Keine Treffer"
|
||||||
|
text="Kein Eintrag passt zu Suche und Filter."
|
||||||
|
aktion={<Button href="/protokoll">Filter zurücksetzen</Button>}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Leerzustand
|
||||||
|
icon={<ScrollText className="size-5" />}
|
||||||
|
titel="Noch keine Einträge"
|
||||||
|
text="Sobald jemand sich anmeldet oder etwas ändert, steht es hier."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Karte>
|
||||||
|
{/* key: neue Filter oder ein neuer Cursor beginnen eine frische Liste. */}
|
||||||
|
<ProtokollListe
|
||||||
|
key={`${query}|${String(vor ?? "")}`}
|
||||||
|
seite={seite}
|
||||||
|
query={query.replace(/^\?/, "")}
|
||||||
|
/>
|
||||||
|
</Karte>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,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,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,148 @@
|
|||||||
|
/**
|
||||||
|
* 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"),
|
||||||
|
|
||||||
|
// 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,255 @@
|
|||||||
|
import { createSocket, type Socket as UdpSocket } from "node:dgram";
|
||||||
|
import { hostname } from "node:os";
|
||||||
|
import { connect, type Socket as TcpSocket } from "node:net";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weiterleitung des Protokolls an Syslog (#027), RFC 5424 mit strukturierten Daten.
|
||||||
|
*
|
||||||
|
* Bewusst klein und ohne Abhaengigkeit: ein paar Zeilen Format, ein UDP- oder TCP-Socket.
|
||||||
|
* Die Kopie beim Syslog-Server ist der zweite, vom sshkv-Host unabhaengige Beleg – wer die
|
||||||
|
* Datenbank uebernimmt, kann die Hash-Kette zwar neu rechnen, aber nicht zurueckholen, was
|
||||||
|
* schon beim Syslog liegt (PLAN §3).
|
||||||
|
*
|
||||||
|
* Es gehen nur die Felder hinaus, die das Protokoll ohnehin geschwaerzt speichert und die
|
||||||
|
* Uebersicht zeigt – `details` und `diff` nie, dort koennten Pfade und Dateiinhalte stehen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SyslogProtokoll = "udp" | "tcp";
|
||||||
|
|
||||||
|
export type SyslogKonfig = {
|
||||||
|
readonly host: string;
|
||||||
|
readonly port: number;
|
||||||
|
readonly protokoll: SyslogProtokoll;
|
||||||
|
readonly appName: string;
|
||||||
|
/** Facility-Nummer nach RFC 5424 (authpriv = 10). */
|
||||||
|
readonly facility: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FACILITIES: Readonly<Record<string, number>> = {
|
||||||
|
kern: 0,
|
||||||
|
user: 1,
|
||||||
|
daemon: 3,
|
||||||
|
auth: 4,
|
||||||
|
syslog: 5,
|
||||||
|
authpriv: 10,
|
||||||
|
local0: 16,
|
||||||
|
local1: 17,
|
||||||
|
local2: 18,
|
||||||
|
local3: 19,
|
||||||
|
local4: 20,
|
||||||
|
local5: 21,
|
||||||
|
local6: 22,
|
||||||
|
local7: 23,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Severity aus dem Ergebnis: Erfolg ist bemerkenswert, Verweigerung eine Warnung. */
|
||||||
|
export const SEVERITY = { ERFOLG: 5, VERWEIGERT: 4, FEHLER: 3 } as const;
|
||||||
|
|
||||||
|
export type SyslogEintrag = {
|
||||||
|
readonly id?: bigint | string | null;
|
||||||
|
readonly zeitpunkt: Date;
|
||||||
|
readonly akteur: string;
|
||||||
|
readonly aktion: string;
|
||||||
|
readonly ergebnis: keyof typeof SEVERITY;
|
||||||
|
readonly hostName?: string | null;
|
||||||
|
readonly zielbenutzer?: string | null;
|
||||||
|
readonly fingerprint?: string | null;
|
||||||
|
readonly hash?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** IANA-Private-Enterprise-Number fuer Beispiele/Eigenbau ist 32473 (RFC 5612). */
|
||||||
|
export const SD_ID = "sshkv@32473";
|
||||||
|
|
||||||
|
/** RFC 5426 verlangt mindestens 480, empfiehlt 2048 Byte; 2048 passt auch in UDP sicher. */
|
||||||
|
export const HOECHSTLAENGE = 2048;
|
||||||
|
/** Je SD-Wert in Byte vor dem Escapen; acht Werte passen so mit Kopf in 2048 Byte. */
|
||||||
|
const WERT_HOECHSTBYTES = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Konfiguration aus der Umgebung. Ohne `SSHKV_SYSLOG_HOST` ist Syslog aus; Fehler
|
||||||
|
* in den uebrigen Werten fallen auf die Standards zurueck, statt den Start zu verhindern.
|
||||||
|
*/
|
||||||
|
export function syslogKonfigAus(umgebung: Record<string, string | undefined>): SyslogKonfig | null {
|
||||||
|
const host = umgebung["SSHKV_SYSLOG_HOST"]?.trim();
|
||||||
|
if (!host) return null;
|
||||||
|
const port = Number(umgebung["SSHKV_SYSLOG_PORT"] ?? 514);
|
||||||
|
const protokoll = umgebung["SSHKV_SYSLOG_PROTOKOLL"]?.trim().toLowerCase() === "tcp" ? "tcp" : "udp";
|
||||||
|
const appName = asciiDruckbar(umgebung["SSHKV_SYSLOG_APPNAME"]?.trim() || "sshkv", 48) || "sshkv";
|
||||||
|
const facilityName = umgebung["SSHKV_SYSLOG_FACILITY"]?.trim().toLowerCase() || "authpriv";
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 514,
|
||||||
|
protokoll,
|
||||||
|
appName,
|
||||||
|
facility: FACILITIES[facilityName] ?? FACILITIES["authpriv"]!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kopffelder duerfen nur druckbares ASCII ohne Leerzeichen enthalten (RFC 5424 §6). */
|
||||||
|
function asciiDruckbar(wert: string, laenge: number): string {
|
||||||
|
return wert.replace(/[^\x21-\x7e]/g, "_").slice(0, laenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Werte in SD-PARAM: `"`, `\` und `]` mit Backslash (RFC 5424 §6.3.3). */
|
||||||
|
export function sdWertEscapen(wert: string): string {
|
||||||
|
return wert.replace(/["\\\]]/g, (zeichen) => `\\${zeichen}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kuerzt vor dem Escapen, damit kein Backslash ohne sein Zeichen am Ende steht. */
|
||||||
|
function sdWert(wert: string | null | undefined, bytes: number): string {
|
||||||
|
// Steuerzeichen (Zeilenumbrueche) wuerden manche Empfaenger zum Zeilenende verleiten.
|
||||||
|
const sauber = (wert ?? "").replace(/[\x00-\x1f\x7f]/g, " ");
|
||||||
|
return sdWertEscapen(textKuerzen(sauber, bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kuerzt auf hoechstens `bytes` UTF-8-Bytes, ohne ein Zeichen zu zerschneiden. */
|
||||||
|
export function textKuerzen(text: string, bytes: number): string {
|
||||||
|
if (Buffer.byteLength(text, "utf8") <= bytes) return text;
|
||||||
|
let ergebnis = "";
|
||||||
|
let laenge = 0;
|
||||||
|
for (const zeichen of text) {
|
||||||
|
const groesse = Buffer.byteLength(zeichen, "utf8");
|
||||||
|
if (laenge + groesse + 3 > bytes) break;
|
||||||
|
ergebnis += zeichen;
|
||||||
|
laenge += groesse;
|
||||||
|
}
|
||||||
|
return `${ergebnis}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut eine vollstaendige RFC-5424-Zeile (ohne TCP-Rahmen). */
|
||||||
|
export function syslogNachricht(
|
||||||
|
eintrag: SyslogEintrag,
|
||||||
|
konfig: Pick<SyslogKonfig, "appName" | "facility">,
|
||||||
|
optionen: { rechner?: string; prozess?: number; text?: string } = {},
|
||||||
|
): string {
|
||||||
|
const pri = konfig.facility * 8 + SEVERITY[eintrag.ergebnis];
|
||||||
|
const rechner = asciiDruckbar(optionen.rechner ?? hostname(), 255) || "-";
|
||||||
|
const prozess = String(optionen.prozess ?? process.pid);
|
||||||
|
const msgId = asciiDruckbar(eintrag.aktion, 32) || "-";
|
||||||
|
const parameter: [string, string | null | undefined][] = [
|
||||||
|
["id", eintrag.id == null ? null : String(eintrag.id)],
|
||||||
|
["akteur", eintrag.akteur],
|
||||||
|
["aktion", eintrag.aktion],
|
||||||
|
["ergebnis", eintrag.ergebnis],
|
||||||
|
["host", eintrag.hostName],
|
||||||
|
["zielbenutzer", eintrag.zielbenutzer],
|
||||||
|
["fingerprint", eintrag.fingerprint],
|
||||||
|
["hash", eintrag.hash],
|
||||||
|
];
|
||||||
|
const kopfMit = (wertBytes: number) => {
|
||||||
|
const sd = `[${SD_ID}${parameter
|
||||||
|
.filter(([, wert]) => wert != null && wert !== "")
|
||||||
|
.map(([name, wert]) => ` ${name}="${sdWert(wert, wertBytes)}"`)
|
||||||
|
.join("")}]`;
|
||||||
|
return `<${pri}>1 ${eintrag.zeitpunkt.toISOString()} ${rechner} ${konfig.appName} ${prozess} ${msgId} ${sd}`;
|
||||||
|
};
|
||||||
|
let kopf = kopfMit(WERT_HOECHSTBYTES);
|
||||||
|
// Nur bei absichtlich langen Werten voller Sonderzeichen: dann alle Werte knapper.
|
||||||
|
if (Buffer.byteLength(kopf, "utf8") > HOECHSTLAENGE - 64) kopf = kopfMit(32);
|
||||||
|
const text =
|
||||||
|
optionen.text ??
|
||||||
|
`${eintrag.akteur}: ${eintrag.aktion} ${eintrag.ergebnis}${eintrag.hostName ? ` auf ${eintrag.hostName}` : ""}`;
|
||||||
|
// Ohne BOM gilt MSG als beliebige Bytes; mit BOM wissen Empfaenger, dass es UTF-8 ist.
|
||||||
|
const rest = HOECHSTLAENGE - Buffer.byteLength(kopf, "utf8") - 4;
|
||||||
|
return `${kopf} \uFEFF${textKuerzen(text.replace(/[\r\n]+/g, " "), rest)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Oktettzaehlung nach RFC 6587 – robuster als Zeilenumbruch als Trenner. */
|
||||||
|
export function tcpRahmen(nachricht: string): Buffer {
|
||||||
|
const daten = Buffer.from(nachricht, "utf8");
|
||||||
|
return Buffer.concat([Buffer.from(`${daten.length} `, "ascii"), daten]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Beschreibung fuer die Oberflaeche – Ziel ohne Zugangsdaten (es gibt keine). */
|
||||||
|
export function syslogZielText(konfig: SyslogKonfig): string {
|
||||||
|
const host = konfig.host.includes(":") ? `[${konfig.host}]` : konfig.host;
|
||||||
|
return `${konfig.protokoll}://${host}:${konfig.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SyslogSender = {
|
||||||
|
senden(eintrag: SyslogEintrag): void;
|
||||||
|
schliessen(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sender mit Feuer-und-vergessen-Semantik: `senden()` wirft nie und wartet auf nichts.
|
||||||
|
* Fehler gehen an `fehlerMelden`, und zwar hoechstens einmal je Minute – ein ausgefallener
|
||||||
|
* Syslog-Server soll das Log nicht fluten und darf nie eine Aktion verhindern.
|
||||||
|
*/
|
||||||
|
export function syslogSender(
|
||||||
|
konfig: SyslogKonfig,
|
||||||
|
fehlerMelden: (fehler: unknown) => void = (fehler) =>
|
||||||
|
console.error("[sshkv] Syslog nicht erreichbar:", fehler instanceof Error ? fehler.message : fehler),
|
||||||
|
jetzt: () => number = Date.now,
|
||||||
|
): SyslogSender {
|
||||||
|
let zuletztGemeldet = -Infinity;
|
||||||
|
const melden = (fehler: unknown) => {
|
||||||
|
const t = jetzt();
|
||||||
|
if (t - zuletztGemeldet < 60_000) return;
|
||||||
|
zuletztGemeldet = t;
|
||||||
|
try {
|
||||||
|
fehlerMelden(fehler);
|
||||||
|
} catch {
|
||||||
|
// Auch ein kaputter Logger darf hier nichts abbrechen.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let udp: UdpSocket | null = null;
|
||||||
|
let tcp: TcpSocket | null = null;
|
||||||
|
|
||||||
|
const udpSocket = () => {
|
||||||
|
if (udp) return udp;
|
||||||
|
const socket = createSocket(konfig.host.includes(":") ? "udp6" : "udp4");
|
||||||
|
socket.on("error", (fehler) => {
|
||||||
|
melden(fehler);
|
||||||
|
socket.close();
|
||||||
|
if (udp === socket) udp = null;
|
||||||
|
});
|
||||||
|
// Der Socket soll den Prozess (Worker, Tests) nicht am Beenden hindern.
|
||||||
|
socket.unref();
|
||||||
|
udp = socket;
|
||||||
|
return socket;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tcpSocket = () => {
|
||||||
|
if (tcp && !tcp.destroyed) return tcp;
|
||||||
|
const socket = connect({ host: konfig.host, port: konfig.port });
|
||||||
|
socket.setKeepAlive(true);
|
||||||
|
socket.on("error", (fehler) => {
|
||||||
|
melden(fehler);
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
socket.on("close", () => {
|
||||||
|
if (tcp === socket) tcp = null;
|
||||||
|
});
|
||||||
|
socket.unref();
|
||||||
|
tcp = socket;
|
||||||
|
return socket;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
senden(eintrag) {
|
||||||
|
try {
|
||||||
|
const nachricht = syslogNachricht(eintrag, konfig);
|
||||||
|
if (konfig.protokoll === "tcp") {
|
||||||
|
// Schreibt vor dem Verbindungsaufbau in den Puffer des Sockets; bei Fehlern gehen
|
||||||
|
// diese Zeilen verloren – die Datenbank bleibt die Quelle der Wahrheit.
|
||||||
|
tcpSocket().write(tcpRahmen(nachricht));
|
||||||
|
} else {
|
||||||
|
const daten = Buffer.from(nachricht, "utf8");
|
||||||
|
udpSocket().send(daten, konfig.port, konfig.host, (fehler) => {
|
||||||
|
if (fehler) melden(fehler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
melden(fehler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
schliessen() {
|
||||||
|
udp?.close();
|
||||||
|
udp = null;
|
||||||
|
tcp?.end();
|
||||||
|
tcp = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "@/lib/validierung";
|
||||||
|
import { akteurVon } from "@/server/actions/formular";
|
||||||
|
import type { VorgangsAntwort } from "@/server/actions/verwendungen";
|
||||||
|
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { DeploymentKeyFehler, rotationsSchrittAnfordern, umstellen } from "@/server/services/deploymentkey";
|
||||||
|
|
||||||
|
/** Rotation des Deployment-Keys (#031). Nur Admins mit frischer Bestaetigung. */
|
||||||
|
|
||||||
|
function alsAntwort(fehler: unknown): { fehler: string } {
|
||||||
|
if (
|
||||||
|
fehler instanceof DeploymentKeyFehler ||
|
||||||
|
fehler instanceof RechteFehler ||
|
||||||
|
fehler instanceof BestaetigungFehler
|
||||||
|
) {
|
||||||
|
return { fehler: fehler.message };
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotationsSchrittAktion(
|
||||||
|
schritt: "erzeugen" | "pruefen" | "abschliessen",
|
||||||
|
): Promise<VorgangsAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
|
||||||
|
const geprueft = z.enum(["erzeugen", "pruefen", "abschliessen"]).parse(schritt);
|
||||||
|
return { vorgangId: await rotationsSchrittAnfordern(geprueft, akteurVon(benutzer)) };
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function umstellenAktion(uebergehen: readonly string[]): Promise<{ fehler?: string }> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
|
||||||
|
await umstellen(akteurVon(benutzer), z.array(z.string().min(1).max(64)).max(500).parse(uebergehen));
|
||||||
|
revalidatePath("/deployment-key");
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { empfaengerAus, type BenachrichtigungEingabe } from "@/lib/benachrichtigung/einstellungen";
|
||||||
|
import { ANLAESSE, type AnlassArt } from "@/lib/benachrichtigung/regeln";
|
||||||
|
import {
|
||||||
|
akteurVon,
|
||||||
|
fehlerZustand,
|
||||||
|
listenfeld,
|
||||||
|
textfeld,
|
||||||
|
type FormularZustand,
|
||||||
|
} from "@/server/actions/formular";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { benachrichtigungSpeichern, testAnfordern } from "@/server/services/benachrichtigung";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
|
||||||
|
/** Einstellungen speichern und Testnachrichten anstossen (#028). Nur `einstellungenVerwalten`. */
|
||||||
|
|
||||||
|
function felder(formular: FormData): BenachrichtigungEingabe {
|
||||||
|
const ja = (name: string) => formular.get(name) === "ja";
|
||||||
|
return {
|
||||||
|
emailAktiv: ja("emailAktiv"),
|
||||||
|
emailServer: textfeld(formular, "emailServer") ?? "",
|
||||||
|
emailPort: textfeld(formular, "emailPort") ?? "",
|
||||||
|
emailSicherheit: (textfeld(formular, "emailSicherheit") ??
|
||||||
|
"STARTTLS") as BenachrichtigungEingabe["emailSicherheit"],
|
||||||
|
emailAbsender: textfeld(formular, "emailAbsender") ?? "",
|
||||||
|
emailBenutzer: textfeld(formular, "emailBenutzer") ?? "",
|
||||||
|
emailPasswort: textfeld(formular, "emailPasswort"),
|
||||||
|
emailPasswortEntfernen: ja("emailPasswortEntfernen"),
|
||||||
|
emailEmpfaenger: empfaengerAus(textfeld(formular, "emailEmpfaenger") ?? ""),
|
||||||
|
webhookAktiv: ja("webhookAktiv"),
|
||||||
|
webhookUrl: textfeld(formular, "webhookUrl") ?? "",
|
||||||
|
webhookHttpErlaubt: ja("webhookHttpErlaubt"),
|
||||||
|
webhookGeheimnis: textfeld(formular, "webhookGeheimnis"),
|
||||||
|
webhookGeheimnisEntfernen: ja("webhookGeheimnisEntfernen"),
|
||||||
|
anlaesse: listenfeld(formular, "anlaesse").filter((a): a is AnlassArt =>
|
||||||
|
(ANLAESSE as readonly string[]).includes(a),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function benachrichtigungSpeichernAktion(
|
||||||
|
_vorher: FormularZustand,
|
||||||
|
formular: FormData,
|
||||||
|
): Promise<FormularZustand & { gespeichert?: boolean }> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("einstellungenVerwalten");
|
||||||
|
await benachrichtigungSpeichern(felder(formular), akteurVon(benutzer));
|
||||||
|
} catch (fehler) {
|
||||||
|
return fehlerZustand(fehler);
|
||||||
|
}
|
||||||
|
revalidatePath("/einstellungen");
|
||||||
|
return { gespeichert: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testSendenAktion(kanal: string): Promise<{ vorgangId?: string; fehler?: string }> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("einstellungenVerwalten");
|
||||||
|
if (kanal !== "EMAIL" && kanal !== "WEBHOOK") return { fehler: "Unbekannter Kanal." };
|
||||||
|
return { vorgangId: await testAnfordern(kanal, benutzer.id) };
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler || fehler instanceof FachFehler) return { fehler: fehler.message };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { cursorAus, protokollFilterAus } from "@/lib/protokoll-filter";
|
||||||
|
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
|
import { protokollSeite, type ProtokollSeite } from "@/server/queries/protokoll";
|
||||||
|
import { kettePruefenZwischengespeichert } from "@/server/services/protokoll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protokollansicht (#027): nur lesende Aktionen. Bewusst gibt es hier nichts, was einen
|
||||||
|
* Eintrag aendert oder loescht – das verbietet ohnehin der Trigger, und
|
||||||
|
* `tests/protokoll-ansicht.test.ts` haelt fest, dass auch kein Code es versucht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type KettenAntwort = {
|
||||||
|
readonly fehlerhafteId?: string | null;
|
||||||
|
readonly eintraege?: number;
|
||||||
|
readonly geprueftAm?: string;
|
||||||
|
readonly fehler?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function kettePruefenAktion(): Promise<KettenAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await rechtErforderlich("lesen");
|
||||||
|
const ergebnis = await kettePruefenZwischengespeichert({ id: benutzer.id, name: benutzer.name });
|
||||||
|
return {
|
||||||
|
fehlerhafteId: ergebnis.fehlerhafteId,
|
||||||
|
eintraege: ergebnis.eintraege,
|
||||||
|
geprueftAm: ergebnis.geprueftAm.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „Ältere laden“: dieselben GET-Filter wie die Seite, Cursor ist die kleinste gezeigte ID. */
|
||||||
|
export async function aeltereLadenAktion(
|
||||||
|
query: string,
|
||||||
|
vor: string,
|
||||||
|
): Promise<ProtokollSeite | { fehler: string }> {
|
||||||
|
try {
|
||||||
|
await rechtErforderlich("lesen");
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
const cursor = cursorAus(String(vor));
|
||||||
|
if (cursor === undefined) return { zeilen: [], weiterVor: null };
|
||||||
|
const filter = protokollFilterAus(Object.fromEntries(new URLSearchParams(String(query).slice(0, 2000))));
|
||||||
|
return protokollSeite(filter, cursor);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
publicVerwendungEntfernen,
|
publicVerwendungEntfernen,
|
||||||
trockenlaufStarten,
|
trockenlaufStarten,
|
||||||
} from "@/server/services/verwendungen";
|
} from "@/server/services/verwendungen";
|
||||||
|
import { instanzZiele } from "@/server/services/deploymentkey";
|
||||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
|
/** Public-Key-Zuweisungen und Ausrollen (#016). Duenn: Recht, Eingabe, Service. */
|
||||||
@@ -78,10 +79,16 @@ export type AusrollAuswahl =
|
|||||||
| { readonly schluesselId: string }
|
| { readonly schluesselId: string }
|
||||||
| { readonly hostId: string }
|
| { readonly hostId: string }
|
||||||
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
|
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
|
||||||
| { readonly schluesselIds: readonly string[] };
|
| { readonly schluesselIds: readonly string[] }
|
||||||
|
/** Alle Hosts mit dem Instanz-Deployment-Key – fuer dessen Rotation (#031). */
|
||||||
|
| { readonly deploymentKey: true };
|
||||||
|
|
||||||
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
|
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
|
||||||
try {
|
try {
|
||||||
|
if ("deploymentKey" in auswahl) {
|
||||||
|
const { benutzer } = await rechtErforderlich("deploymentKeyVerwalten");
|
||||||
|
return { vorgangId: await trockenlaufStarten({ ziele: await instanzZiele() }, akteurVon(benutzer)) };
|
||||||
|
}
|
||||||
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||||
const id = z.string().min(1).max(64);
|
const id = z.string().min(1).max(64);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { alsAnsicht, type BenachrichtigungAnsicht } from "@/lib/benachrichtigung/einstellungen";
|
||||||
|
import { cronBeschreiben, webhookZielKurz } from "@/lib/benachrichtigung/regeln";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { VERSION } from "@/lib/version";
|
||||||
|
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
|
||||||
|
import { benachrichtigungLesen } from "@/server/services/benachrichtigung";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodell der Einstellungsseite (#028). Lesen duerfen alle Rollen; Geheimnisse verlassen
|
||||||
|
* diese Datei nur als „gesetzt ja/nein“ (`alsAnsicht`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ZustellungZeile = {
|
||||||
|
readonly sendung: string;
|
||||||
|
readonly zeitpunkt: Date;
|
||||||
|
readonly kanal: "EMAIL" | "WEBHOOK";
|
||||||
|
readonly betreff: string;
|
||||||
|
readonly anlaesse: number;
|
||||||
|
readonly ergebnis: "GESENDET" | "FEHLGESCHLAGEN";
|
||||||
|
readonly versuche: number;
|
||||||
|
readonly fehler: string | null;
|
||||||
|
readonly naechsterVersuchAm: Date | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Zeitplan = { readonly ausdruck: string; readonly beschreibung: string | null };
|
||||||
|
|
||||||
|
export type EinstellungenSeite = {
|
||||||
|
readonly benachrichtigung: BenachrichtigungAnsicht;
|
||||||
|
readonly webhookZiel: string | null;
|
||||||
|
readonly zustellungen: readonly ZustellungZeile[];
|
||||||
|
readonly abgleich: Zeitplan;
|
||||||
|
readonly meldung: Zeitplan;
|
||||||
|
readonly instanz: {
|
||||||
|
readonly version: string;
|
||||||
|
readonly deploymentKey: { readonly name: string; readonly fingerprint: string } | null;
|
||||||
|
readonly oidc: { readonly name: string; readonly konfiguriert: boolean };
|
||||||
|
readonly notfall: { readonly vorhanden: boolean; readonly totpEingerichtet: boolean };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function zeitplan(variable: string, standard: string): Zeitplan {
|
||||||
|
const ausdruck = process.env[variable]?.trim() || standard;
|
||||||
|
return { ausdruck, beschreibung: cronBeschreiben(ausdruck) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Je Nachricht eine Zeile: alle Anlaesse einer Sendung teilen Kennung, Betreff und Ergebnis. */
|
||||||
|
export async function letzteZustellungen(anzahl = 12): Promise<ZustellungZeile[]> {
|
||||||
|
const zeilen = await prisma.zustellung.findMany({
|
||||||
|
orderBy: { zeitpunkt: "desc" },
|
||||||
|
take: 500,
|
||||||
|
select: {
|
||||||
|
sendung: true,
|
||||||
|
zeitpunkt: true,
|
||||||
|
kanal: true,
|
||||||
|
betreff: true,
|
||||||
|
ergebnis: true,
|
||||||
|
versuche: true,
|
||||||
|
fehler: true,
|
||||||
|
naechsterVersuchAm: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const gruppen = new Map<string, ZustellungZeile>();
|
||||||
|
for (const z of zeilen) {
|
||||||
|
const vorhanden = gruppen.get(z.sendung);
|
||||||
|
if (vorhanden) {
|
||||||
|
gruppen.set(z.sendung, {
|
||||||
|
...vorhanden,
|
||||||
|
anlaesse: vorhanden.anlaesse + 1,
|
||||||
|
versuche: Math.max(vorhanden.versuche, z.versuche),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (gruppen.size >= anzahl) continue;
|
||||||
|
gruppen.set(z.sendung, { ...z, anlaesse: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...gruppen.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function einstellungenSeite(): Promise<EinstellungenSeite> {
|
||||||
|
const [gespeichert, zustellungen, deploymentKey, notfall] = await Promise.all([
|
||||||
|
benachrichtigungLesen(),
|
||||||
|
letzteZustellungen(),
|
||||||
|
prisma.schluessel.findFirst({
|
||||||
|
where: { instanzStandard: true },
|
||||||
|
select: { name: true, fingerprint: true },
|
||||||
|
}),
|
||||||
|
prisma.benutzer.findFirst({ where: { istNotfall: true }, select: { totpEingerichtetAm: true } }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
benachrichtigung: alsAnsicht(gespeichert),
|
||||||
|
webhookZiel: gespeichert.webhook.url ? webhookZielKurz(gespeichert.webhook.url) : null,
|
||||||
|
zustellungen,
|
||||||
|
abgleich: zeitplan("SSHKV_SYNC_CRON", "30 2 * * *"),
|
||||||
|
meldung: zeitplan("SSHKV_MELDUNG_CRON", "0 7 * * *"),
|
||||||
|
instanz: {
|
||||||
|
version: VERSION,
|
||||||
|
deploymentKey,
|
||||||
|
oidc: { name: oidcName(), konfiguriert: oidcAktiv() },
|
||||||
|
notfall: { vorhanden: notfall !== null, totpEingerichtet: Boolean(notfall?.totpEingerichtetAm) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { aktionenDerGruppe } from "@/lib/protokoll-aktionen";
|
||||||
|
import { zeitraum, type ProtokollFilter } from "@/lib/protokoll-filter";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodell des Protokolls (#027). Jede Rolle darf das ganze Protokoll lesen (PLAN §4);
|
||||||
|
* die Rechtepruefung machen Seite, Action und Route selbst.
|
||||||
|
*
|
||||||
|
* Geblaettert wird ueber die ID („älter als“), nicht ueber OFFSET: das Protokoll waechst
|
||||||
|
* waehrend man liest, und OFFSET wuerde dann Zeilen doppelt zeigen oder ueberspringen –
|
||||||
|
* ausserdem muesste die Datenbank bei OFFSET 10 000 erst 10 000 Zeilen lesen und verwerfen.
|
||||||
|
*/
|
||||||
|
export const SEITENGROESSE = 50;
|
||||||
|
|
||||||
|
export type ProtokollZeile = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly zeitpunkt: string;
|
||||||
|
readonly akteurName: string;
|
||||||
|
readonly aktion: string;
|
||||||
|
readonly ergebnis: "ERFOLG" | "FEHLER" | "VERWEIGERT";
|
||||||
|
readonly hostName: string | null;
|
||||||
|
readonly zielbenutzer: string | null;
|
||||||
|
readonly fingerprint: string | null;
|
||||||
|
readonly diff: string | null;
|
||||||
|
/** Bereits beim Schreiben geschwaerzt (#005); oberste Ebene als Liste, Werte formatiert. */
|
||||||
|
readonly details: { readonly name: string; readonly wert: string }[] | null;
|
||||||
|
readonly vorgaengerHash: string;
|
||||||
|
readonly hash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUSWAHL = {
|
||||||
|
id: true,
|
||||||
|
zeitpunkt: true,
|
||||||
|
akteurName: true,
|
||||||
|
aktion: true,
|
||||||
|
ergebnis: true,
|
||||||
|
hostName: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
fingerprint: true,
|
||||||
|
diff: true,
|
||||||
|
details: true,
|
||||||
|
vorgaengerHash: true,
|
||||||
|
hash: true,
|
||||||
|
} satisfies Prisma.ProtokollSelect;
|
||||||
|
|
||||||
|
type Rohzeile = Prisma.ProtokollGetPayload<{ select: typeof AUSWAHL }>;
|
||||||
|
|
||||||
|
export function protokollWhere(filter: ProtokollFilter, vor?: bigint): Prisma.ProtokollWhereInput {
|
||||||
|
const und: Prisma.ProtokollWhereInput[] = [];
|
||||||
|
const { ab, vor: bis } = zeitraum(filter);
|
||||||
|
if (ab || bis) und.push({ zeitpunkt: { ...(ab ? { gte: ab } : {}), ...(bis ? { lt: bis } : {}) } });
|
||||||
|
if (filter.gruppe) und.push({ aktion: { in: aktionenDerGruppe(filter.gruppe) } });
|
||||||
|
if (filter.akteur) und.push({ akteurName: filter.akteur });
|
||||||
|
if (filter.host) und.push({ hostName: { contains: filter.host, mode: "insensitive" } });
|
||||||
|
if (filter.ergebnis) und.push({ ergebnis: filter.ergebnis });
|
||||||
|
if (filter.q) {
|
||||||
|
const enthaelt = { contains: filter.q, mode: "insensitive" } as const;
|
||||||
|
und.push({ OR: [{ fingerprint: enthaelt }, { hostName: enthaelt }, { zielbenutzer: enthaelt }] });
|
||||||
|
}
|
||||||
|
if (vor !== undefined) und.push({ id: { lt: vor } });
|
||||||
|
return und.length ? { AND: und } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Texte bleiben Texte; Zahlen, Listen und Objekte als eingerueckte JSON. */
|
||||||
|
function detailsListe(details: Prisma.JsonValue): ProtokollZeile["details"] {
|
||||||
|
if (details == null) return null;
|
||||||
|
const alsText = (wert: unknown) => (typeof wert === "string" ? wert : JSON.stringify(wert, null, 2));
|
||||||
|
if (typeof details !== "object" || Array.isArray(details))
|
||||||
|
return [{ name: "Wert", wert: alsText(details) }];
|
||||||
|
const eintraege = Object.entries(details).map(([name, wert]) => ({ name, wert: alsText(wert) }));
|
||||||
|
return eintraege.length ? eintraege : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zeileAus(z: Rohzeile): ProtokollZeile {
|
||||||
|
return {
|
||||||
|
id: String(z.id),
|
||||||
|
zeitpunkt: z.zeitpunkt.toISOString(),
|
||||||
|
akteurName: z.akteurName,
|
||||||
|
aktion: z.aktion,
|
||||||
|
ergebnis: z.ergebnis,
|
||||||
|
hostName: z.hostName,
|
||||||
|
zielbenutzer: z.zielbenutzer,
|
||||||
|
fingerprint: z.fingerprint,
|
||||||
|
diff: z.diff,
|
||||||
|
details: detailsListe(z.details),
|
||||||
|
vorgaengerHash: z.vorgaengerHash,
|
||||||
|
hash: z.hash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProtokollSeite = {
|
||||||
|
readonly zeilen: ProtokollZeile[];
|
||||||
|
/** ID fuer „ältere laden“; fehlt, wenn es nichts Aelteres mehr gibt. */
|
||||||
|
readonly weiterVor: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Eine Seite, neueste zuerst; `vor` ist die kleinste ID der vorigen Seite. */
|
||||||
|
export async function protokollSeite(filter: ProtokollFilter, vor?: bigint): Promise<ProtokollSeite> {
|
||||||
|
const roh = await prisma.protokoll.findMany({
|
||||||
|
where: protokollWhere(filter, vor),
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
take: SEITENGROESSE + 1,
|
||||||
|
select: AUSWAHL,
|
||||||
|
});
|
||||||
|
const mehr = roh.length > SEITENGROESSE;
|
||||||
|
const zeilen = roh.slice(0, SEITENGROESSE).map(zeileAus);
|
||||||
|
return { zeilen, weiterVor: mehr ? (zeilen.at(-1)?.id ?? null) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kopfzeile der Seite: Gesamtzahl und Treffer des Filters. */
|
||||||
|
export async function protokollZaehlen(
|
||||||
|
filter: ProtokollFilter,
|
||||||
|
): Promise<{ gesamt: number; treffer: number }> {
|
||||||
|
const [gesamt, treffer] = await Promise.all([
|
||||||
|
prisma.protokoll.count(),
|
||||||
|
prisma.protokoll.count({ where: protokollWhere(filter) }),
|
||||||
|
]);
|
||||||
|
return { gesamt, treffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Namen fuer den Akteur-Filter – haeufigste zuerst, begrenzt, damit die Liste handlich bleibt. */
|
||||||
|
export async function protokollAkteure(): Promise<string[]> {
|
||||||
|
const gruppen = await prisma.protokoll.groupBy({
|
||||||
|
by: ["akteurName"],
|
||||||
|
_count: { _all: true },
|
||||||
|
orderBy: { _count: { akteurName: "desc" } },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
return gruppen.map((g) => g.akteurName).sort((a, b) => a.localeCompare(b, "de"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Treffer in Stapeln, neueste zuerst – fuer den CSV-Export. Liest je Stapel nur
|
||||||
|
* `stapel` Zeilen, damit auch ein grosses Protokoll nie ganz im Speicher liegt.
|
||||||
|
*/
|
||||||
|
export async function* protokollStapelweise(
|
||||||
|
filter: ProtokollFilter,
|
||||||
|
stapel = 500,
|
||||||
|
): AsyncGenerator<Rohzeile[], void, undefined> {
|
||||||
|
let vor: bigint | undefined;
|
||||||
|
for (;;) {
|
||||||
|
const zeilen = await prisma.protokoll.findMany({
|
||||||
|
where: protokollWhere(filter, vor),
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
take: stapel,
|
||||||
|
select: AUSWAHL,
|
||||||
|
});
|
||||||
|
if (zeilen.length === 0) return;
|
||||||
|
yield zeilen;
|
||||||
|
if (zeilen.length < stapel) return;
|
||||||
|
vor = zeilen.at(-1)!.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Rohzeile as ProtokollRohzeile };
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type { AbweichungsArt } from "@/generated/prisma/enums";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { aktionText } from "@/lib/protokoll-aktionen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lesemodell der Übersicht (#026). Alle Zahlen werden berechnet, nichts ist hinterlegt –
|
||||||
|
* Kacheln und Listen können so nie einander widersprechen.
|
||||||
|
*/
|
||||||
|
const TAG_MS = 86_400_000;
|
||||||
|
|
||||||
|
const BEFUND_TEXT: Record<AbweichungsArt, [string, string]> = {
|
||||||
|
UNBEKANNTER_SCHLUESSEL: ["unbekannter Schlüssel", "unbekannte Schlüssel"],
|
||||||
|
FEHLENDER_SCHLUESSEL: ["Schlüssel fehlt", "Schlüssel fehlen"],
|
||||||
|
ZUSAETZLICH_IM_BLOCK: ["Block von Hand geändert", "Block von Hand geändert"],
|
||||||
|
DATEI_GEAENDERT: ["Block beschädigt", "Blöcke beschädigt"],
|
||||||
|
RECHTE: ["Rechte falsch", "Rechte falsch"],
|
||||||
|
HOSTKEY_GEAENDERT: ["Host-Key geändert", "Host-Key geändert"],
|
||||||
|
PRIVATE_KEY_FEHLT: ["Private Key fehlt", "Private Keys fehlen"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** „2 unbekannte Schlüssel · Rechte falsch“ aus den Befunden eines Host/Benutzer-Paars. */
|
||||||
|
export function befundZusammenfassen(arten: readonly AbweichungsArt[]): string {
|
||||||
|
const anzahl = new Map<AbweichungsArt, number>();
|
||||||
|
for (const art of arten) anzahl.set(art, (anzahl.get(art) ?? 0) + 1);
|
||||||
|
// Feste Reihenfolge (die von BEFUND_TEXT), damit die Zeile nicht je nach Datenbank springt.
|
||||||
|
return (Object.keys(BEFUND_TEXT) as AbweichungsArt[])
|
||||||
|
.filter((art) => anzahl.has(art))
|
||||||
|
.map((art) => [art, anzahl.get(art)!] as const)
|
||||||
|
.map(([art, n]) => {
|
||||||
|
const [einzahl, mehrzahl] = BEFUND_TEXT[art];
|
||||||
|
return n === 1 ? (art === "UNBEKANNTER_SCHLUESSEL" ? `1 ${einzahl}` : einzahl) : `${n} ${mehrzahl}`;
|
||||||
|
})
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AktionsArt =
|
||||||
|
| "ausgerollt"
|
||||||
|
| "widerrufen"
|
||||||
|
| "erstellt"
|
||||||
|
| "abgleich"
|
||||||
|
| "rotiert"
|
||||||
|
| "anmeldung"
|
||||||
|
| "sonstiges"
|
||||||
|
| "verweigert";
|
||||||
|
|
||||||
|
/** Grobe Einordnung fuer die Farbe in „Letzte Aktionen“; die Ansicht im Audit-Log ist genauer (#027). */
|
||||||
|
export function aktionEinordnen(aktion: string, ergebnis: string): AktionsArt {
|
||||||
|
if (ergebnis !== "ERFOLG") return "verweigert";
|
||||||
|
if (aktion.startsWith("authorized_keys.") || aktion.startsWith("privatekey.ausgerollt"))
|
||||||
|
return "ausgerollt";
|
||||||
|
if (aktion.includes("widerruf")) return "widerrufen";
|
||||||
|
if (aktion.includes("rotation") || aktion === "deploymentkey.umgestellt") return "rotiert";
|
||||||
|
if (aktion === "abgleich") return "abgleich";
|
||||||
|
if (aktion.startsWith("anmeldung") || aktion.startsWith("bestaetigung")) return "anmeldung";
|
||||||
|
if (
|
||||||
|
aktion.endsWith(".erzeugt") ||
|
||||||
|
aktion.endsWith(".angelegt") ||
|
||||||
|
aktion.endsWith(".importiert") ||
|
||||||
|
aktion.endsWith(".uebernommen")
|
||||||
|
) {
|
||||||
|
return "erstellt";
|
||||||
|
}
|
||||||
|
return "sonstiges";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uebersicht(jetzt = new Date()) {
|
||||||
|
const in30Tagen = new Date(jetzt.getTime() + 30 * TAG_MS);
|
||||||
|
const [hosts, schluesselGesamt, schluesselPrivat, offen, ablaufend, aktionen] = await Promise.all([
|
||||||
|
prisma.host.findMany({
|
||||||
|
where: { archiviertAm: null },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
erreichbar: true,
|
||||||
|
driftZustand: true,
|
||||||
|
letzterAbgleichAm: true,
|
||||||
|
bootstrapAm: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.schluessel.count({ where: { status: "AKTIV", kategorie: { not: "DEPLOYMENT" } } }),
|
||||||
|
prisma.schluessel.count({
|
||||||
|
where: { status: "AKTIV", kategorie: { not: "DEPLOYMENT" }, masterKeyVersion: { not: null } },
|
||||||
|
}),
|
||||||
|
prisma.abweichung.findMany({
|
||||||
|
where: { behobenAm: null, host: { archiviertAm: null } },
|
||||||
|
select: { art: true, zielbenutzer: true, hostId: true, host: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
prisma.schluessel.findMany({
|
||||||
|
where: { status: "AKTIV", ablaufAm: { not: null, lte: in30Tagen } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
typ: true,
|
||||||
|
kategorie: true,
|
||||||
|
ablaufAm: true,
|
||||||
|
person: { select: { name: true } },
|
||||||
|
_count: { select: { publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } } } },
|
||||||
|
},
|
||||||
|
orderBy: { ablaufAm: "asc" },
|
||||||
|
}),
|
||||||
|
// Anmeldungen sind im Audit-Log zu sehen; auf der Uebersicht verdraengten sie sonst alles.
|
||||||
|
prisma.protokoll.findMany({
|
||||||
|
where: { NOT: [{ aktion: { startsWith: "anmeldung" } }, { aktion: { startsWith: "bestaetigung" } }] },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
take: 8,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
zeitpunkt: true,
|
||||||
|
akteurName: true,
|
||||||
|
aktion: true,
|
||||||
|
ergebnis: true,
|
||||||
|
hostName: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Befunde je Host und Benutzer zusammenfassen; nicht erreichbare Hosts zaehlen mit.
|
||||||
|
const gruppen = new Map<
|
||||||
|
string,
|
||||||
|
{ hostId: string; host: string; benutzer: string; arten: AbweichungsArt[] }
|
||||||
|
>();
|
||||||
|
for (const a of offen) {
|
||||||
|
const schluessel = `${a.hostId}\u0000${a.zielbenutzer ?? ""}`;
|
||||||
|
const gruppe = gruppen.get(schluessel) ?? {
|
||||||
|
hostId: a.hostId,
|
||||||
|
host: a.host.name,
|
||||||
|
benutzer: a.zielbenutzer ?? "–",
|
||||||
|
arten: [],
|
||||||
|
};
|
||||||
|
gruppe.arten.push(a.art);
|
||||||
|
gruppen.set(schluessel, gruppe);
|
||||||
|
}
|
||||||
|
const mitAbweichung = [
|
||||||
|
...[...gruppen.values()].map((g) => ({
|
||||||
|
hostId: g.hostId,
|
||||||
|
host: g.host,
|
||||||
|
benutzer: g.benutzer,
|
||||||
|
status: "drift" as const,
|
||||||
|
befund: befundZusammenfassen(g.arten),
|
||||||
|
})),
|
||||||
|
...hosts
|
||||||
|
.filter((h) => h.erreichbar === false)
|
||||||
|
.map((h) => ({
|
||||||
|
hostId: h.id,
|
||||||
|
host: h.name,
|
||||||
|
benutzer: "–",
|
||||||
|
status: "unerreichbar" as const,
|
||||||
|
befund: "nicht erreichbar",
|
||||||
|
})),
|
||||||
|
].sort((a, b) => a.host.localeCompare(b.host));
|
||||||
|
|
||||||
|
const letzterAbgleich = hosts.reduce<Date | null>(
|
||||||
|
(spaetester, h) =>
|
||||||
|
h.letzterAbgleichAm && (!spaetester || h.letzterAbgleichAm > spaetester)
|
||||||
|
? h.letzterAbgleichAm
|
||||||
|
: spaetester,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const abgelaufen = ablaufend.filter((s) => s.ablaufAm! < jetzt).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
letzterAbgleich,
|
||||||
|
kennzahlen: {
|
||||||
|
hosts: {
|
||||||
|
wert: hosts.length,
|
||||||
|
hinweis: `${hosts.filter((h) => h.driftZustand === "SYNCHRON").length} synchron · ${hosts.filter((h) => h.erreichbar === false).length} nicht erreichbar`,
|
||||||
|
},
|
||||||
|
schluessel: { wert: schluesselGesamt, hinweis: `davon ${schluesselPrivat} mit Private Key` },
|
||||||
|
abweichungen: {
|
||||||
|
wert: offen.length,
|
||||||
|
hinweis: `auf ${new Set(offen.map((a) => a.hostId)).size} Host(s)`,
|
||||||
|
},
|
||||||
|
ablaufend: {
|
||||||
|
wert: ablaufend.length - abgelaufen,
|
||||||
|
hinweis: abgelaufen ? `${abgelaufen} bereits abgelaufen` : "in den nächsten 30 Tagen",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mitAbweichung: mitAbweichung.slice(0, 8),
|
||||||
|
weitereAbweichungen: Math.max(0, mitAbweichung.length - 8),
|
||||||
|
baldAblaufend: ablaufend.slice(0, 6).map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
zweck: s.name,
|
||||||
|
person: s.person?.name ?? (s.kategorie === "DIENST" ? "Dienst" : "Person"),
|
||||||
|
typ: s.typ === "RSA4096" ? "rsa-4096" : s.typ === "ED25519" ? "ed25519" : "anderer",
|
||||||
|
ablauf: s.ablaufAm!,
|
||||||
|
ausgerollt: s._count.publicVerwendungen > 0,
|
||||||
|
})),
|
||||||
|
letzteAktionen: aktionen.map((a) => ({
|
||||||
|
id: a.id.toString(),
|
||||||
|
zeit: a.zeitpunkt,
|
||||||
|
art: aktionEinordnen(a.aktion, a.ergebnis),
|
||||||
|
aktion: a.aktion,
|
||||||
|
// Lesbarer Text aus dem Audit-Log (#027) statt des technischen Aktionsnamens.
|
||||||
|
beschreibung: [
|
||||||
|
aktionText(a.aktion),
|
||||||
|
a.zielbenutzer && a.hostName ? `${a.zielbenutzer}@${a.hostName}` : a.hostName,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · "),
|
||||||
|
benutzer: a.akteurName,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Uebersicht = Awaited<ReturnType<typeof uebersicht>>;
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
import {
|
||||||
|
benachrichtigungSchema,
|
||||||
|
EINSTELLUNG_BENACHRICHTIGUNG,
|
||||||
|
gespeichertLesen,
|
||||||
|
type BenachrichtigungEingabe,
|
||||||
|
type GespeicherteBenachrichtigung,
|
||||||
|
} from "@/lib/benachrichtigung/einstellungen";
|
||||||
|
import { ANLAESSE, type AnlassArt } from "@/lib/benachrichtigung/regeln";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { feldfehlerAus } from "@/lib/hosts";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import type { Akteur } from "@/server/services/hosts";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benachrichtigungs-Einstellungen lesen und speichern (#028). Geheimnisse werden hier
|
||||||
|
* verschluesselt und verlassen diese Datei nie im Klartext – entschluesselt wird nur beim
|
||||||
|
* Versand im Worker.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Gespeicherter Stand inkl. Chiffraten – nur fuer Service und Worker, nie fuer die Oberflaeche. */
|
||||||
|
export async function benachrichtigungLesen(): Promise<GespeicherteBenachrichtigung> {
|
||||||
|
const eintrag = await prisma.einstellung.findUnique({
|
||||||
|
where: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG },
|
||||||
|
});
|
||||||
|
return gespeichertLesen(eintrag?.wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function benachrichtigungSpeichern(
|
||||||
|
eingabe: BenachrichtigungEingabe,
|
||||||
|
akteur: Akteur,
|
||||||
|
): Promise<void> {
|
||||||
|
const geprueft = benachrichtigungSchema.safeParse(eingabe);
|
||||||
|
if (!geprueft.success) {
|
||||||
|
throw new FachFehler("Bitte die markierten Felder prüfen.", feldfehlerAus(geprueft.error));
|
||||||
|
}
|
||||||
|
const e = geprueft.data;
|
||||||
|
const vorher = await benachrichtigungLesen();
|
||||||
|
|
||||||
|
// Leeres Feld heisst „unveraendert“ – die Oberflaeche kennt das alte Geheimnis ja nicht.
|
||||||
|
const passwortNeu = e.emailPasswort ? e.emailPasswort : undefined;
|
||||||
|
const geheimnisNeu = e.webhookGeheimnis ? e.webhookGeheimnis : undefined;
|
||||||
|
const passwort = e.emailPasswortEntfernen
|
||||||
|
? null
|
||||||
|
: passwortNeu !== undefined
|
||||||
|
? verschluesseln(passwortNeu)
|
||||||
|
: vorher.email.passwort;
|
||||||
|
const geheimnis = e.webhookGeheimnisEntfernen
|
||||||
|
? null
|
||||||
|
: geheimnisNeu !== undefined
|
||||||
|
? verschluesseln(geheimnisNeu)
|
||||||
|
: vorher.webhook.geheimnis;
|
||||||
|
|
||||||
|
const anlaesse = Object.fromEntries(ANLAESSE.map((a) => [a, e.anlaesse.includes(a)])) as Record<
|
||||||
|
AnlassArt,
|
||||||
|
boolean
|
||||||
|
>;
|
||||||
|
const neu: GespeicherteBenachrichtigung = {
|
||||||
|
email: {
|
||||||
|
aktiv: e.emailAktiv,
|
||||||
|
server: e.emailServer,
|
||||||
|
port: e.emailPort,
|
||||||
|
sicherheit: e.emailSicherheit,
|
||||||
|
absender: e.emailAbsender,
|
||||||
|
benutzer: e.emailBenutzer,
|
||||||
|
passwort,
|
||||||
|
empfaenger: e.emailEmpfaenger,
|
||||||
|
},
|
||||||
|
webhook: { aktiv: e.webhookAktiv, url: e.webhookUrl, httpErlaubt: e.webhookHttpErlaubt, geheimnis },
|
||||||
|
anlaesse,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.einstellung.upsert({
|
||||||
|
where: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG },
|
||||||
|
create: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG, wert: neu as unknown as Prisma.InputJsonValue },
|
||||||
|
update: { wert: neu as unknown as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "einstellungen.benachrichtigungen.geaendert",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
// Nur, ob sich ein Geheimnis geaendert hat – nie der Wert, auch nicht verschluesselt. Die
|
||||||
|
// Namen meiden „passwort“/„geheimnis“, sonst schwaerzt schwaerzen() schon den Wahrheitswert.
|
||||||
|
details: {
|
||||||
|
emailAktiv: neu.email.aktiv,
|
||||||
|
smtpServer: neu.email.server,
|
||||||
|
empfaenger: neu.email.empfaenger.length,
|
||||||
|
webhookAktiv: neu.webhook.aktiv,
|
||||||
|
webhookHttp: neu.webhook.httpErlaubt,
|
||||||
|
smtpZugangGeaendert: passwort !== vorher.email.passwort,
|
||||||
|
signaturSchluesselGeaendert: geheimnis !== vorher.webhook.geheimnis,
|
||||||
|
anlaesse: ANLAESSE.filter((a) => anlaesse[a]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist ein Kanal so weit eingetragen, dass ein Versand ueberhaupt Sinn hat? */
|
||||||
|
export function kanalVollstaendig(e: GespeicherteBenachrichtigung, kanal: "EMAIL" | "WEBHOOK"): boolean {
|
||||||
|
if (kanal === "EMAIL") return Boolean(e.email.server && e.email.absender && e.email.empfaenger.length > 0);
|
||||||
|
return Boolean(e.webhook.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testnachricht als Vorgang im Worker (#028): dort liegt auch der regulaere Versand, und der
|
||||||
|
* Webprozess braucht so keinen Weg zu Mailserver oder Webhook-Ziel. Getestet wird der
|
||||||
|
* gespeicherte Stand – auch wenn der Kanal noch ausgeschaltet ist, damit man vor dem
|
||||||
|
* Einschalten pruefen kann.
|
||||||
|
*/
|
||||||
|
export async function testAnfordern(kanal: "EMAIL" | "WEBHOOK", benutzerId: string): Promise<string> {
|
||||||
|
const einstellungen = await benachrichtigungLesen();
|
||||||
|
if (!kanalVollstaendig(einstellungen, kanal)) {
|
||||||
|
throw new FachFehler(
|
||||||
|
kanal === "EMAIL"
|
||||||
|
? "Erst Server, Absender und Empfänger speichern."
|
||||||
|
: "Erst eine Webhook-Adresse speichern.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return vorgangEinreihen({ art: "BENACHRICHTIGUNG_TEST", angefordertVonId: benutzerId, eingabe: { kanal } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { anmeldebenutzerVon, deploymentNachfolger, type Ziel } from "@/server/services/soll";
|
||||||
|
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation des Instanz-Deployment-Keys (#031) – geführt in fünf Schritten:
|
||||||
|
*
|
||||||
|
* 1. erzeugen: Nachfolger anlegen (DEPLOYMENT, nicht Standard, vorgaengerId = alter).
|
||||||
|
* 2. verteilen: Dry-Run + Ausrollen auf alle Hosts mit Instanz-Key – der Nachfolger kommt
|
||||||
|
* **neben** den alten in den Block (soll.ts).
|
||||||
|
* 3. pruefen: je Host eine Anmeldung mit dem neuen Key.
|
||||||
|
* 4. umstellen: Nachfolger wird Standard, alter ROTIERT; danach Dry-Run + Ausrollen entfernt den
|
||||||
|
* alten aus allen Blöcken.
|
||||||
|
* 5. abschliessen: auf jedem Host nachsehen, dass der alte weg ist, dann seinen Private Key
|
||||||
|
* verwerfen.
|
||||||
|
*
|
||||||
|
* Zu keinem Zeitpunkt steht ein Host ohne funktionierenden Deployment-Key da: umgestellt wird
|
||||||
|
* erst, wenn der neue Key überall geprüft ist (oder ein Admin einen Host bewusst übergeht –
|
||||||
|
* der braucht danach einen neuen Bootstrap).
|
||||||
|
*/
|
||||||
|
export class DeploymentKeyFehler extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "DeploymentKeyFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Akteur = { readonly id: string; readonly name: string };
|
||||||
|
|
||||||
|
export type PruefErgebnis = { hostId: string; hostName: string; ok: boolean; fehler?: string };
|
||||||
|
|
||||||
|
/** Hosts, die den Instanz-Key benutzen (kein eigener, keiner über eine Gruppe). */
|
||||||
|
export async function instanzHosts() {
|
||||||
|
return prisma.host.findMany({
|
||||||
|
where: {
|
||||||
|
archiviertAm: null,
|
||||||
|
bootstrapAm: { not: null },
|
||||||
|
deploymentSchluesselId: null,
|
||||||
|
gruppen: { none: { hostgruppe: { deploymentSchluesselId: { not: null } } } },
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, anmeldeart: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function instanzZiele(): Promise<Ziel[]> {
|
||||||
|
return (await instanzHosts()).map((h) => ({
|
||||||
|
hostId: h.id,
|
||||||
|
zielbenutzer: anmeldebenutzerVon(h),
|
||||||
|
art: "public",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotationsStand() {
|
||||||
|
const aktuell = await prisma.schluessel.findFirst({
|
||||||
|
where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||||
|
select: { id: true, fingerprint: true, angelegtAm: true, vorgaengerId: true },
|
||||||
|
});
|
||||||
|
const nachfolger = await deploymentNachfolger();
|
||||||
|
// Nach dem Umstellen: der alte ist ROTIERT, sein Private Key noch nicht verworfen.
|
||||||
|
const vorgaenger = aktuell?.vorgaengerId
|
||||||
|
? await prisma.schluessel.findFirst({
|
||||||
|
where: {
|
||||||
|
id: aktuell.vorgaengerId,
|
||||||
|
kategorie: "DEPLOYMENT",
|
||||||
|
status: "ROTIERT",
|
||||||
|
privatGeloeschtAm: null,
|
||||||
|
},
|
||||||
|
select: { id: true, fingerprint: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const letztePruefung = await prisma.vorgang.findFirst({
|
||||||
|
where: { art: "ROTATION", eingabe: { path: ["schritt"], equals: "pruefen" } },
|
||||||
|
orderBy: { angelegtAm: "desc" },
|
||||||
|
select: { id: true, status: true, ergebnis: true, beendetAm: true, angelegtAm: true },
|
||||||
|
});
|
||||||
|
const hosts = await instanzHosts();
|
||||||
|
const pruefung =
|
||||||
|
letztePruefung && nachfolger && letztePruefung.angelegtAm > nachfolger.angelegtAm
|
||||||
|
? ((letztePruefung.ergebnis as { hosts?: PruefErgebnis[] } | null)?.hosts ?? null)
|
||||||
|
: null;
|
||||||
|
const phase: "bereit" | "verteilen" | "aufraeumen" = nachfolger
|
||||||
|
? "verteilen"
|
||||||
|
: vorgaenger
|
||||||
|
? "aufraeumen"
|
||||||
|
: "bereit";
|
||||||
|
return { aktuell, nachfolger, vorgaenger, phase, hosts, pruefung };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotationsSchrittAnfordern(
|
||||||
|
schritt: "erzeugen" | "pruefen" | "abschliessen",
|
||||||
|
akteur: Akteur,
|
||||||
|
): Promise<string> {
|
||||||
|
const stand = await rotationsStand();
|
||||||
|
if (schritt === "erzeugen" && stand.phase !== "bereit") {
|
||||||
|
throw new DeploymentKeyFehler("Es läuft bereits eine Rotation des Deployment-Keys.");
|
||||||
|
}
|
||||||
|
if (schritt === "pruefen" && !stand.nachfolger)
|
||||||
|
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key zu prüfen.");
|
||||||
|
if (schritt === "abschliessen" && !stand.vorgaenger)
|
||||||
|
throw new DeploymentKeyFehler("Es gibt nichts abzuschließen.");
|
||||||
|
return vorgangEinreihen({ art: "ROTATION", angefordertVonId: akteur.id, eingabe: { schritt } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schritt 4: Nachfolger wird Standard. Verlangt eine Prüfung nach dem Erzeugen, in der jeder
|
||||||
|
* Host den neuen Key angenommen hat – oder ausdrücklich übergangen wird.
|
||||||
|
*/
|
||||||
|
export async function umstellen(akteur: Akteur, uebergehen: readonly string[] = []): Promise<void> {
|
||||||
|
const stand = await rotationsStand();
|
||||||
|
if (!stand.nachfolger || !stand.aktuell)
|
||||||
|
throw new DeploymentKeyFehler("Es gibt keinen neuen Deployment-Key.");
|
||||||
|
if (!stand.pruefung) throw new DeploymentKeyFehler("Erst den neuen Key auf allen Hosts prüfen.");
|
||||||
|
const geprueft = new Map(stand.pruefung.map((p) => [p.hostId, p]));
|
||||||
|
const offen = stand.hosts.filter((h) => !geprueft.get(h.id)?.ok && !uebergehen.includes(h.id));
|
||||||
|
if (offen.length) {
|
||||||
|
throw new DeploymentKeyFehler(
|
||||||
|
`Der neue Key ist noch nicht auf allen Hosts geprüft: ${offen.map((h) => h.name).join(", ")}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const alt = stand.aktuell;
|
||||||
|
const neu = stand.nachfolger;
|
||||||
|
await prisma.$transaction([
|
||||||
|
// Erst den alten abloesen, dann den neuen setzen – der partielle Index erlaubt nur einen Standard.
|
||||||
|
prisma.schluessel.update({
|
||||||
|
where: { id: alt.id },
|
||||||
|
data: { instanzStandard: false, status: "ROTIERT" },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
prisma.schluessel.update({
|
||||||
|
where: { id: neu.id },
|
||||||
|
data: { instanzStandard: true },
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "deploymentkey.umgestellt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: neu.fingerprint,
|
||||||
|
details: { alt: alt.fingerprint, uebergangen: [...uebergehen] },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { Prisma } from "@/generated/prisma/client";
|
|||||||
import type { ProtokollErgebnis } from "@/generated/prisma/enums";
|
import type { ProtokollErgebnis } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { schwaerzen } from "@/lib/protokoll-schwaerzen";
|
import { schwaerzen } from "@/lib/protokoll-schwaerzen";
|
||||||
|
import { syslogKonfigAus, syslogSender, type SyslogKonfig, type SyslogSender } from "@/lib/syslog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
|
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
|
||||||
@@ -26,7 +27,7 @@ export async function protokollieren(
|
|||||||
client: Prisma.TransactionClient = prisma,
|
client: Prisma.TransactionClient = prisma,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur;
|
const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur;
|
||||||
await client.protokoll.create({
|
const zeile = await client.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
akteurId: akteur.id,
|
akteurId: akteur.id,
|
||||||
akteurName: akteur.name,
|
akteurName: akteur.name,
|
||||||
@@ -40,13 +41,119 @@ export async function protokollieren(
|
|||||||
diff: eintrag.diff ? (schwaerzen(eintrag.diff) as string) : null,
|
diff: eintrag.diff ? (schwaerzen(eintrag.diff) as string) : null,
|
||||||
details: eintrag.details ? (schwaerzen(eintrag.details) as Prisma.InputJsonValue) : undefined,
|
details: eintrag.details ? (schwaerzen(eintrag.details) as Prisma.InputJsonValue) : undefined,
|
||||||
},
|
},
|
||||||
|
select: { id: true, zeitpunkt: true, hash: true },
|
||||||
|
});
|
||||||
|
// Erst nach dem erfolgreichen Schreiben, ohne zu warten (#027): Syslog ist die Kopie,
|
||||||
|
// die Datenbank die Quelle. Innerhalb einer Transaktion kann ein spaeterer Rollback eine
|
||||||
|
// Syslog-Zeile ohne Gegenstueck hinterlassen – deren Hash fehlt dann in der Kette und faellt
|
||||||
|
// beim Vergleich auf; umgekehrt (Eintrag ohne Syslog-Zeile) waere schlimmer.
|
||||||
|
syslogWeitergeben({
|
||||||
|
id: zeile.id,
|
||||||
|
zeitpunkt: zeile.zeitpunkt,
|
||||||
|
akteur: akteur.name,
|
||||||
|
aktion: eintrag.aktion,
|
||||||
|
ergebnis: eintrag.ergebnis,
|
||||||
|
hostName: eintrag.hostName ?? null,
|
||||||
|
zielbenutzer: eintrag.zielbenutzer ?? null,
|
||||||
|
fingerprint: eintrag.fingerprint ?? null,
|
||||||
|
hash: zeile.hash,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Prueft die Hash-Kette in der Datenbank; liefert die erste fehlerhafte ID oder null. */
|
let syslog: { konfig: SyslogKonfig; sender: SyslogSender } | null | undefined;
|
||||||
|
|
||||||
|
/** Konfiguration einmal je Prozess lesen; Web und Worker haben jeder einen Sender. */
|
||||||
|
export function syslogKonfig(): SyslogKonfig | null {
|
||||||
|
if (syslog === undefined) {
|
||||||
|
const konfig = syslogKonfigAus(process.env);
|
||||||
|
syslog = konfig ? { konfig, sender: syslogSender(konfig) } : null;
|
||||||
|
}
|
||||||
|
return syslog?.konfig ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur fuer Tests: Umgebung neu lesen. */
|
||||||
|
export function syslogZuruecksetzen(): void {
|
||||||
|
syslog?.sender.schliessen();
|
||||||
|
syslog = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syslogWeitergeben(eintrag: Parameters<SyslogSender["senden"]>[0]): void {
|
||||||
|
try {
|
||||||
|
if (!syslogKonfig()) return;
|
||||||
|
syslog?.sender.senden(eintrag);
|
||||||
|
} catch {
|
||||||
|
// senden() wirft nicht; das hier faengt nur eine kaputte Konfiguration ab.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft die Hash-Kette in der Datenbank; liefert die erste fehlerhafte ID oder null.
|
||||||
|
* Liest jede Zeile – fuer die Oberflaeche `ketteGeprueft()` mit Zwischenspeicher nehmen.
|
||||||
|
*/
|
||||||
export async function protokollKettePruefen(): Promise<bigint | null> {
|
export async function protokollKettePruefen(): Promise<bigint | null> {
|
||||||
const [zeile] = await prisma.$queryRaw<
|
const [zeile] = await prisma.$queryRaw<
|
||||||
{ ergebnis: bigint | null }[]
|
{ ergebnis: bigint | null }[]
|
||||||
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
||||||
return zeile?.ergebnis ?? null;
|
return zeile?.ergebnis ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Kettenpruefung = {
|
||||||
|
readonly fehlerhafteId: string | null;
|
||||||
|
readonly eintraege: number;
|
||||||
|
readonly geprueftAm: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wie lange ein Pruefergebnis gilt, bevor „Kette prüfen“ wieder rechnet (#027). */
|
||||||
|
export const KETTE_ZWISCHENSPEICHER_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
let letztePruefung: Kettenpruefung | null = null;
|
||||||
|
let laufendePruefung: Promise<Kettenpruefung> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Pruefung rechnet SHA-256 ueber jede Zeile und waechst mit dem Protokoll; deshalb laeuft
|
||||||
|
* sie nie beim blossen Seitenaufruf, sondern auf Knopfdruck, und das Ergebnis gilt 5 Minuten
|
||||||
|
* je Prozess. Gleichzeitige Klicks teilen sich eine laufende Pruefung (#027).
|
||||||
|
*/
|
||||||
|
export function ketteZwischengespeichert(jetzt: Date = new Date()): Kettenpruefung | null {
|
||||||
|
if (!letztePruefung) return null;
|
||||||
|
return jetzt.getTime() - letztePruefung.geprueftAm.getTime() < KETTE_ZWISCHENSPEICHER_MS
|
||||||
|
? letztePruefung
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kettePruefenZwischengespeichert(
|
||||||
|
akteur: ProtokollEintrag["akteur"],
|
||||||
|
): Promise<Kettenpruefung> {
|
||||||
|
const vorhanden = ketteZwischengespeichert();
|
||||||
|
if (vorhanden) return vorhanden;
|
||||||
|
laufendePruefung ??= (async () => {
|
||||||
|
try {
|
||||||
|
const eintraege = await prisma.protokoll.count();
|
||||||
|
const fehlerhaft = await protokollKettePruefen();
|
||||||
|
const ergebnis = {
|
||||||
|
fehlerhafteId: fehlerhaft === null ? null : String(fehlerhaft),
|
||||||
|
eintraege,
|
||||||
|
geprueftAm: new Date(),
|
||||||
|
};
|
||||||
|
if (fehlerhaft !== null) {
|
||||||
|
// Ein Bruch ist ein Sicherheitsereignis und soll auch beim Syslog ankommen.
|
||||||
|
await protokollieren({
|
||||||
|
akteur,
|
||||||
|
aktion: "protokoll.kette_beschaedigt",
|
||||||
|
ergebnis: "FEHLER",
|
||||||
|
details: { fehlerhafteId: String(fehlerhaft) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
letztePruefung = ergebnis;
|
||||||
|
return ergebnis;
|
||||||
|
} finally {
|
||||||
|
laufendePruefung = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return laufendePruefung;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur fuer Tests. */
|
||||||
|
export function ketteZwischenspeicherLeeren(): void {
|
||||||
|
letztePruefung = null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,23 @@ async function deploymentKeyFuer(host: Pick<Host, "id" | "deploymentSchluesselId
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Nachfolger des Instanz-Deployment-Keys waehrend einer Rotation (#031): neu erzeugt,
|
||||||
|
* aber noch nicht Standard. Er gehoert schon in jeden Block – neben den alten –, damit kein
|
||||||
|
* Host je ohne funktionierenden Deployment-Key dasteht.
|
||||||
|
*/
|
||||||
|
export async function deploymentNachfolger() {
|
||||||
|
return prisma.schluessel.findFirst({
|
||||||
|
where: {
|
||||||
|
kategorie: "DEPLOYMENT",
|
||||||
|
instanzStandard: false,
|
||||||
|
status: "AKTIV",
|
||||||
|
vorgaenger: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" },
|
||||||
|
},
|
||||||
|
select: { id: true, publicKey: true, fingerprint: true, vorgaengerId: true, angelegtAm: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function sollFuer(hostId: string, zielbenutzer: string): Promise<Soll> {
|
export async function sollFuer(hostId: string, zielbenutzer: string): Promise<Soll> {
|
||||||
const host = await prisma.host.findUniqueOrThrow({
|
const host = await prisma.host.findUniqueOrThrow({
|
||||||
where: { id: hostId },
|
where: { id: hostId },
|
||||||
@@ -77,6 +94,18 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
|
|||||||
eintraege.push({ publicKey: deployment.publicKey, fingerprint: deployment.fingerprint });
|
eintraege.push({ publicKey: deployment.publicKey, fingerprint: deployment.fingerprint });
|
||||||
pflicht.push(deployment.fingerprint);
|
pflicht.push(deployment.fingerprint);
|
||||||
}
|
}
|
||||||
|
// Nur Hosts mit dem Instanz-Key nehmen an dessen Rotation teil (#031).
|
||||||
|
const eigener = host.deploymentSchluesselId !== null;
|
||||||
|
const nachfolger = eigener ? null : await deploymentNachfolger();
|
||||||
|
if (nachfolger && (await deploymentKeyFuer(host))?.fingerprint !== nachfolger.fingerprint) {
|
||||||
|
const gruppe = await prisma.hostgruppeMitglied.count({
|
||||||
|
where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } },
|
||||||
|
});
|
||||||
|
if (gruppe === 0) {
|
||||||
|
eintraege.push({ publicKey: nachfolger.publicKey, fingerprint: nachfolger.fingerprint });
|
||||||
|
pflicht.push(nachfolger.fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const drin: string[] = [];
|
const drin: string[] = [];
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { PgBoss } from "pg-boss";
|
|||||||
/** Eine Queue je Vorgangsart haelt Wiederholungen und Gleichzeitigkeit getrennt einstellbar. */
|
/** Eine Queue je Vorgangsart haelt Wiederholungen und Gleichzeitigkeit getrennt einstellbar. */
|
||||||
export const WARTESCHLANGE = "sshkv-vorgang";
|
export const WARTESCHLANGE = "sshkv-vorgang";
|
||||||
export const ZEITPLAN_ABGLEICH = "sshkv-abgleich-zeitplan";
|
export const ZEITPLAN_ABGLEICH = "sshkv-abgleich-zeitplan";
|
||||||
|
/** Taegliche Zusammenfassung und Fuenf-Minuten-Takt der Benachrichtigungen (#028). */
|
||||||
|
export const ZEITPLAN_MELDUNG = "sshkv-meldung-zeitplan";
|
||||||
|
export const ZEITPLAN_MELDUNG_TAKT = "sshkv-meldung-takt";
|
||||||
|
|
||||||
/** Was im Job steht. Absichtlich nur die ID: alles Weitere liegt im `Vorgang`. */
|
/** Was im Job steht. Absichtlich nur die ID: alles Weitere liegt im `Vorgang`. */
|
||||||
export type VorgangsJob = {
|
export type VorgangsJob = {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||||
|
import { testVersenden } from "@/worker/benachrichtigung/versand";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorgang BENACHRICHTIGUNG_TEST (#028): Testnachricht ueber einen Kanal. Ein Fehlschlag laesst
|
||||||
|
* den Vorgang scheitern – die Oberflaeche zeigt dann den bereinigten Fehlertext.
|
||||||
|
*/
|
||||||
|
export const benachrichtigungTestHandler: Handler = async ({ vorgang }) => {
|
||||||
|
const kanal = (vorgang.eingabe as { kanal?: unknown } | null)?.kanal;
|
||||||
|
if (kanal !== "EMAIL" && kanal !== "WEBHOOK") throw new VorgangsFehler("Unbekannter Kanal.");
|
||||||
|
const benutzer = vorgang.angefordertVonId
|
||||||
|
? await prisma.benutzer.findUnique({
|
||||||
|
where: { id: vorgang.angefordertVonId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const fehler = await testVersenden(kanal, benutzer ?? undefined);
|
||||||
|
if (fehler) throw new VorgangsFehler(fehler);
|
||||||
|
return { ergebnis: { kanal, gesendet: true } };
|
||||||
|
};
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { hostname } from "node:os";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { authorizedKeysLesen, type SchluesselZeile } from "@/lib/ssh/authorized-keys";
|
||||||
|
import { instanzHosts, rotationsStand, type PruefErgebnis } from "@/server/services/deploymentkey";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { anmeldebenutzerVon } from "@/server/services/soll";
|
||||||
|
import { VorgangsFehler, type Handler } from "@/worker/ausfuehren";
|
||||||
|
import { privateKeyLaden, privateKeyVerwerfen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
|
||||||
|
import { hostVerbinden } from "@/worker/ssh/host";
|
||||||
|
import { authorizedKeysHolen } from "@/worker/ssh/ssh-dateien";
|
||||||
|
import { befehlAusfuehren } from "@/worker/ssh/verbindung";
|
||||||
|
|
||||||
|
/** Worker-Schritte der Rotation des Deployment-Keys (#031): erzeugen, pruefen, abschliessen. */
|
||||||
|
|
||||||
|
function fehlerText(fehler: unknown): string {
|
||||||
|
return fehler instanceof VorgangsFehler ? fehler.message : "Verbindung fehlgeschlagen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function akteurVon(id: string | null) {
|
||||||
|
const benutzer = id
|
||||||
|
? await prisma.benutzer.findUnique({ where: { id }, select: { id: true, name: true } })
|
||||||
|
: null;
|
||||||
|
return benutzer ?? ("System" as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function erzeugen(angefordertVonId: string | null) {
|
||||||
|
const stand = await rotationsStand();
|
||||||
|
if (!stand.aktuell || stand.phase !== "bereit") throw new VorgangsFehler("Es läuft bereits eine Rotation.");
|
||||||
|
const datum = new Date().toISOString().slice(0, 10);
|
||||||
|
const kommentar = `sshkv-deployment@${hostname()}-${datum}`;
|
||||||
|
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", kommentar);
|
||||||
|
const anzahl = await prisma.schluessel.count({ where: { kategorie: "DEPLOYMENT" } });
|
||||||
|
await prisma.schluessel.create({
|
||||||
|
data: {
|
||||||
|
id: neu.id,
|
||||||
|
name: `Deployment-Key der Instanz (${datum})`,
|
||||||
|
slug: `sshkv-deployment-${anzahl + 1}`,
|
||||||
|
typ: "ED25519",
|
||||||
|
kategorie: "DEPLOYMENT",
|
||||||
|
publicKey: neu.publicKeyZeile,
|
||||||
|
fingerprint: neu.fingerprint,
|
||||||
|
kommentar,
|
||||||
|
vorgaengerId: stand.aktuell.id,
|
||||||
|
...neu.versiegelt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await protokollieren({
|
||||||
|
akteur: await akteurVon(angefordertVonId),
|
||||||
|
aktion: "deploymentkey.erzeugt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: neu.fingerprint,
|
||||||
|
details: { vorgaenger: stand.aktuell.fingerprint },
|
||||||
|
});
|
||||||
|
return { ergebnis: { fingerprint: neu.fingerprint } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldet sich auf jedem Instanz-Host mit dem NEUEN Key an. Wirft nicht je Host. */
|
||||||
|
async function pruefen(angefordertVonId: string | null) {
|
||||||
|
const stand = await rotationsStand();
|
||||||
|
if (!stand.nachfolger) throw new VorgangsFehler("Es gibt keinen neuen Deployment-Key.");
|
||||||
|
const hosts: PruefErgebnis[] = [];
|
||||||
|
const privateKey = await privateKeyLaden(stand.nachfolger.id);
|
||||||
|
try {
|
||||||
|
for (const kurz of await instanzHosts()) {
|
||||||
|
const host = await prisma.host.findUniqueOrThrow({ where: { id: kurz.id } });
|
||||||
|
try {
|
||||||
|
const verbindung = await hostVerbinden(host, { benutzer: anmeldebenutzerVon(host), privateKey });
|
||||||
|
try {
|
||||||
|
const wer = await befehlAusfuehren(verbindung, "id -un");
|
||||||
|
hosts.push({
|
||||||
|
hostId: host.id,
|
||||||
|
hostName: host.name,
|
||||||
|
ok: wer.stdout.trim() === anmeldebenutzerVon(host),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
verbindung.end();
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
hosts.push({ hostId: host.id, hostName: host.name, ok: false, fehler: fehlerText(fehler) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
privateKey.fill(0);
|
||||||
|
}
|
||||||
|
await protokollieren({
|
||||||
|
akteur: await akteurVon(angefordertVonId),
|
||||||
|
aktion: "deploymentkey.geprueft",
|
||||||
|
ergebnis: hosts.every((h) => h.ok) ? "ERFOLG" : "FEHLER",
|
||||||
|
fingerprint: stand.nachfolger.fingerprint,
|
||||||
|
details: {
|
||||||
|
ok: hosts.filter((h) => h.ok).length,
|
||||||
|
fehler: hosts.filter((h) => !h.ok).map((h) => h.hostName),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ergebnis: { hosts } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft auf jedem Instanz-Host, dass der alte Key nicht mehr im Block steht, und verwirft
|
||||||
|
* erst dann seinen Private Key. Nicht erreichbare Hosts verhindern den Abschluss nicht – sie
|
||||||
|
* melden sich mit dem alten Key ohnehin nicht mehr an, weil sshkv ihn nicht mehr benutzt –,
|
||||||
|
* werden aber im Ergebnis genannt.
|
||||||
|
*/
|
||||||
|
async function abschliessen(angefordertVonId: string | null) {
|
||||||
|
const stand = await rotationsStand();
|
||||||
|
if (!stand.vorgaenger) throw new VorgangsFehler("Es gibt nichts abzuschließen.");
|
||||||
|
const nochDa: string[] = [];
|
||||||
|
const nichtErreicht: string[] = [];
|
||||||
|
for (const kurz of await instanzHosts()) {
|
||||||
|
const host = await prisma.host.findUniqueOrThrow({ where: { id: kurz.id } });
|
||||||
|
try {
|
||||||
|
const verbindung = await hostVerbinden(host);
|
||||||
|
try {
|
||||||
|
const stand2 = await authorizedKeysHolen(verbindung, host, anmeldebenutzerVon(host));
|
||||||
|
const zeilen = authorizedKeysLesen(stand2.inhalt).zeilen.filter(
|
||||||
|
(z): z is SchluesselZeile => z.art === "schluessel",
|
||||||
|
);
|
||||||
|
if (zeilen.some((z) => z.fingerprint === stand.vorgaenger!.fingerprint)) nochDa.push(host.name);
|
||||||
|
} finally {
|
||||||
|
verbindung.end();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
nichtErreicht.push(host.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nochDa.length) {
|
||||||
|
throw new VorgangsFehler(
|
||||||
|
`Der alte Deployment-Key steht noch auf: ${nochDa.join(", ")}. Bitte zuerst Dry-Run und Ausrollen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await privateKeyVerwerfen(stand.vorgaenger.id);
|
||||||
|
await protokollieren({
|
||||||
|
akteur: await akteurVon(angefordertVonId),
|
||||||
|
aktion: "deploymentkey.abgeschlossen",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: stand.vorgaenger.fingerprint,
|
||||||
|
details: { nichtErreicht },
|
||||||
|
});
|
||||||
|
return { ergebnis: { nichtErreicht } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deploymentRotationHandler: Handler = async ({ vorgang }) => {
|
||||||
|
const schritt = (vorgang.eingabe as { schritt?: unknown } | null)?.schritt;
|
||||||
|
if (schritt === "erzeugen") return erzeugen(vorgang.angefordertVonId);
|
||||||
|
if (schritt === "pruefen") return pruefen(vorgang.angefordertVonId);
|
||||||
|
if (schritt === "abschliessen") return abschliessen(vorgang.angefordertVonId);
|
||||||
|
throw new VorgangsFehler("Unbekannter Schritt der Rotation.");
|
||||||
|
};
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import type { Posten } from "@/lib/benachrichtigung/posten";
|
||||||
|
import { ablaufStufe, fingerabdruck, type AnlassArt } from "@/lib/benachrichtigung/regeln";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { tageBis } from "@/lib/format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sammelt die meldbaren Anlaesse aus der Datenbank (#028). Der Abgleich selbst meldet nichts:
|
||||||
|
* er schreibt Abweichungen und Erreichbarkeit, und diese Datei liest sie. So bleibt der
|
||||||
|
* Abgleich-Ablauf unberuehrt, und ein verpasster Versand holt sich der naechste Lauf.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AUSGEROLLT = ["AUSGEROLLT", "ABWEICHUNG"] as const;
|
||||||
|
|
||||||
|
async function drift(): Promise<Posten[]> {
|
||||||
|
const offen = await prisma.abweichung.findMany({
|
||||||
|
where: { behobenAm: null, art: { not: "HOSTKEY_GEAENDERT" }, host: { archiviertAm: null } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
art: true,
|
||||||
|
zielbenutzer: true,
|
||||||
|
fingerprint: true,
|
||||||
|
details: true,
|
||||||
|
host: { select: { id: true, name: true, adresse: true } },
|
||||||
|
},
|
||||||
|
orderBy: { eroeffnetAm: "asc" },
|
||||||
|
});
|
||||||
|
return offen.map((a) => ({
|
||||||
|
art: "drift",
|
||||||
|
anlass: fingerabdruck.drift(a.id),
|
||||||
|
host: { hostId: a.host.id, name: a.host.name, adresse: a.host.adresse },
|
||||||
|
befund: {
|
||||||
|
art: a.art,
|
||||||
|
zielbenutzer: a.zielbenutzer,
|
||||||
|
fingerprint: a.fingerprint,
|
||||||
|
text: ((a.details ?? {}) as { text?: unknown }).text?.toString() ?? null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostKey(): Promise<Posten[]> {
|
||||||
|
const offen = await prisma.abweichung.findMany({
|
||||||
|
where: { behobenAm: null, art: "HOSTKEY_GEAENDERT", host: { archiviertAm: null } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
fingerprint: true,
|
||||||
|
details: true,
|
||||||
|
host: { select: { id: true, name: true, adresse: true } },
|
||||||
|
},
|
||||||
|
orderBy: { eroeffnetAm: "asc" },
|
||||||
|
});
|
||||||
|
return offen.map((a) => {
|
||||||
|
const details = (a.details ?? {}) as { erwartet?: unknown; algorithmus?: unknown };
|
||||||
|
return {
|
||||||
|
art: "hostkey",
|
||||||
|
anlass: fingerabdruck.hostkey(a.id),
|
||||||
|
host: { hostId: a.host.id, name: a.host.name, adresse: a.host.adresse },
|
||||||
|
befund: {
|
||||||
|
algorithmus: typeof details.algorithmus === "string" ? details.algorithmus : "unbekannt",
|
||||||
|
erwartet: typeof details.erwartet === "string" ? details.erwartet : null,
|
||||||
|
gesehen: a.fingerprint ?? "unbekannt",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unerreichbar(): Promise<Posten[]> {
|
||||||
|
const hosts = await prisma.host.findMany({
|
||||||
|
where: {
|
||||||
|
archiviertAm: null,
|
||||||
|
bootstrapAm: { not: null },
|
||||||
|
erreichbar: false,
|
||||||
|
// Ein geaenderter Host-Key laesst den Abgleich ebenfalls scheitern; dafuer gibt es die
|
||||||
|
// dringende Meldung (e) – hier waere sie nur ein irrefuehrendes Echo.
|
||||||
|
abweichungen: { none: { art: "HOSTKEY_GEAENDERT", behobenAm: null } },
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, adresse: true, zuletztGeprueftAm: true, letzterAbgleichAm: true },
|
||||||
|
});
|
||||||
|
return hosts.map((h) => ({
|
||||||
|
art: "unerreichbar",
|
||||||
|
anlass: fingerabdruck.unerreichbar(h.id, h.letzterAbgleichAm),
|
||||||
|
host: {
|
||||||
|
hostId: h.id,
|
||||||
|
name: h.name,
|
||||||
|
adresse: h.adresse,
|
||||||
|
zuletztGeprueftAm: h.zuletztGeprueftAm,
|
||||||
|
letzterAbgleichAm: h.letzterAbgleichAm,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ablauf(jetzt: Date): Promise<Posten[]> {
|
||||||
|
const bis = new Date(jetzt.getTime() + 31 * 86_400_000);
|
||||||
|
// Feste select-Liste: Private-Key-Felder kommen hier nie vorbei (CLAUDE.md).
|
||||||
|
const schluessel = await prisma.schluessel.findMany({
|
||||||
|
where: { status: "AKTIV", widerrufEingeleitetAm: null, ablaufAm: { gt: jetzt, lte: bis } },
|
||||||
|
select: { id: true, name: true, fingerprint: true, ablaufAm: true },
|
||||||
|
});
|
||||||
|
const posten: Posten[] = [];
|
||||||
|
for (const s of schluessel) {
|
||||||
|
if (!s.ablaufAm) continue;
|
||||||
|
const stufe = ablaufStufe(s.ablaufAm, jetzt);
|
||||||
|
if (stufe === null) continue;
|
||||||
|
posten.push({
|
||||||
|
art: "ablauf",
|
||||||
|
anlass: fingerabdruck.ablauf(s.id, stufe, s.ablaufAm),
|
||||||
|
schluessel: {
|
||||||
|
schluesselId: s.id,
|
||||||
|
name: s.name,
|
||||||
|
fingerprint: s.fingerprint,
|
||||||
|
ablaufAm: s.ablaufAm,
|
||||||
|
tage: tageBis(s.ablaufAm, jetzt),
|
||||||
|
stufe,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return posten;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abgelaufen(jetzt: Date): Promise<Posten[]> {
|
||||||
|
const schluessel = await prisma.schluessel.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ["AKTIV", "ABGELAUFEN"] },
|
||||||
|
ablaufAm: { lte: jetzt },
|
||||||
|
OR: [
|
||||||
|
{ publicVerwendungen: { some: { zustand: { in: [...AUSGEROLLT] } } } },
|
||||||
|
{ privateVerwendungen: { some: { zustand: { in: [...AUSGEROLLT] } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
fingerprint: true,
|
||||||
|
ablaufAm: true,
|
||||||
|
publicVerwendungen: {
|
||||||
|
where: { zustand: { in: [...AUSGEROLLT] } },
|
||||||
|
select: { host: { select: { name: true } }, hostgruppe: { select: { name: true } } },
|
||||||
|
},
|
||||||
|
privateVerwendungen: {
|
||||||
|
where: { zustand: { in: [...AUSGEROLLT] } },
|
||||||
|
select: { host: { select: { name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return schluessel.flatMap((s): Posten[] => {
|
||||||
|
if (!s.ablaufAm) return [];
|
||||||
|
const hosts = new Set<string>();
|
||||||
|
for (const v of s.publicVerwendungen) {
|
||||||
|
if (v.host) hosts.add(v.host.name);
|
||||||
|
else if (v.hostgruppe) hosts.add(`Gruppe ${v.hostgruppe.name}`);
|
||||||
|
}
|
||||||
|
for (const v of s.privateVerwendungen) hosts.add(v.host.name);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
art: "abgelaufen",
|
||||||
|
anlass: fingerabdruck.abgelaufen(s.id, s.ablaufAm),
|
||||||
|
schluessel: {
|
||||||
|
schluesselId: s.id,
|
||||||
|
name: s.name,
|
||||||
|
fingerprint: s.fingerprint,
|
||||||
|
ablaufAm: s.ablaufAm,
|
||||||
|
hosts: [...hosts].sort((a, b) => a.localeCompare(b, "de")),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Posten der eingeschalteten Anlaesse. */
|
||||||
|
export async function postenSammeln(
|
||||||
|
arten: readonly AnlassArt[],
|
||||||
|
jetzt: Date = new Date(),
|
||||||
|
): Promise<Posten[]> {
|
||||||
|
const sammler: Record<AnlassArt, () => Promise<Posten[]>> = {
|
||||||
|
drift,
|
||||||
|
hostkey: hostKey,
|
||||||
|
unerreichbar,
|
||||||
|
ablauf: () => ablauf(jetzt),
|
||||||
|
abgelaufen: () => abgelaufen(jetzt),
|
||||||
|
};
|
||||||
|
const teile = await Promise.all(arten.map((art) => sammler[art]()));
|
||||||
|
return teile.flat();
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import type { GespeicherteBenachrichtigung, SmtpSicherheit } from "@/lib/benachrichtigung/einstellungen";
|
||||||
|
import { signatur, webhookKoerper, type Nachricht } from "@/lib/benachrichtigung/nachrichten";
|
||||||
|
import { webhookUrlPruefen } from "@/lib/benachrichtigung/regeln";
|
||||||
|
import { entschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { VERSION } from "@/lib/version";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versandwege (#028): SMTP (Microsoft 365 mit STARTTLS auf 587) und Webhook mit HMAC-Signatur.
|
||||||
|
* Uebernommen aus ProxLedger, ergaenzt um Zeitlimits, die Wahl „unverschluesselt“ fuer interne
|
||||||
|
* Relays und Fehlermeldungen ohne Zugangsdaten.
|
||||||
|
*/
|
||||||
|
export class ZustellFehler extends Error {
|
||||||
|
constructor(nachricht: string) {
|
||||||
|
super(nachricht);
|
||||||
|
this.name = "ZustellFehler";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmailZugang = {
|
||||||
|
readonly server: string;
|
||||||
|
readonly port: number;
|
||||||
|
readonly sicherheit: SmtpSicherheit;
|
||||||
|
readonly absender: string;
|
||||||
|
readonly benutzer: string;
|
||||||
|
readonly passwort: string | null;
|
||||||
|
readonly empfaenger: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WebhookZugang = {
|
||||||
|
readonly url: string;
|
||||||
|
readonly httpErlaubt: boolean;
|
||||||
|
readonly geheimnis: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Entschluesselt erst hier, unmittelbar vor dem Versand. */
|
||||||
|
export function zugaengeLesen(e: GespeicherteBenachrichtigung): {
|
||||||
|
email: EmailZugang;
|
||||||
|
webhook: WebhookZugang;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
email: { ...e.email, passwort: e.email.passwort ? entschluesseln(e.email.passwort) : null },
|
||||||
|
webhook: {
|
||||||
|
url: e.webhook.url,
|
||||||
|
httpErlaubt: e.webhook.httpErlaubt,
|
||||||
|
geheimnis: e.webhook.geheimnis ? entschluesseln(e.webhook.geheimnis) : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehlertexte kommen vom Mailserver oder aus dem Netz. Sie landen im Versandprotokoll und in der
|
||||||
|
* Oberflaeche – deshalb gekuerzt und ohne die Zugangsdaten, falls ein Server sie zurueckspiegelt.
|
||||||
|
*/
|
||||||
|
export function fehlerBereinigen(fehler: unknown, geheim: readonly (string | null)[]): string {
|
||||||
|
let text = fehler instanceof Error ? fehler.message : String(fehler);
|
||||||
|
for (const wert of geheim) if (wert) text = text.replaceAll(wert, "***");
|
||||||
|
text = text.replace(/\s+/g, " ").trim();
|
||||||
|
return text.length > 300 ? `${text.slice(0, 297)}…` : text || "Unbekannter Fehler.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function perEmailSenden(zugang: EmailZugang, nachricht: Nachricht): Promise<void> {
|
||||||
|
if (zugang.empfaenger.length === 0) throw new ZustellFehler("Kein Empfänger hinterlegt.");
|
||||||
|
const transport = nodemailer.createTransport({
|
||||||
|
host: zugang.server,
|
||||||
|
port: zugang.port,
|
||||||
|
secure: zugang.sicherheit === "TLS",
|
||||||
|
// Microsoft 365 verlangt STARTTLS; requireTLS verhindert, dass ein Server ohne STARTTLS
|
||||||
|
// die Zugangsdaten im Klartext bekommt.
|
||||||
|
requireTLS: zugang.sicherheit === "STARTTLS",
|
||||||
|
ignoreTLS: zugang.sicherheit === "KEINE",
|
||||||
|
auth: zugang.benutzer ? { user: zugang.benutzer, pass: zugang.passwort ?? "" } : undefined,
|
||||||
|
connectionTimeout: 15_000,
|
||||||
|
greetingTimeout: 15_000,
|
||||||
|
socketTimeout: 30_000,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await transport.sendMail({
|
||||||
|
from: zugang.absender,
|
||||||
|
to: zugang.empfaenger.join(", "),
|
||||||
|
subject: nachricht.betreff,
|
||||||
|
text: nachricht.text,
|
||||||
|
html: nachricht.html,
|
||||||
|
headers: { "X-Sshkv-Ereignis": nachricht.nutzlast.ereignis },
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
throw new ZustellFehler(`SMTP: ${fehlerBereinigen(fehler, [zugang.passwort])}`);
|
||||||
|
} finally {
|
||||||
|
transport.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function perWebhookSenden(zugang: WebhookZugang, nachricht: Nachricht): Promise<void> {
|
||||||
|
// Noch einmal hier: die Einstellung koennte an der Oberflaeche vorbei geschrieben worden sein.
|
||||||
|
const ungueltig = webhookUrlPruefen(zugang.url, zugang.httpErlaubt);
|
||||||
|
if (ungueltig) throw new ZustellFehler(`Webhook: ${ungueltig}`);
|
||||||
|
|
||||||
|
const koerper = webhookKoerper(nachricht);
|
||||||
|
const kopf: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"User-Agent": `sshkv/${VERSION}`,
|
||||||
|
"X-Sshkv-Ereignis": nachricht.nutzlast.ereignis,
|
||||||
|
};
|
||||||
|
if (zugang.geheimnis) kopf["X-Sshkv-Signatur"] = signatur(koerper, zugang.geheimnis);
|
||||||
|
|
||||||
|
let antwort: Response;
|
||||||
|
try {
|
||||||
|
antwort = await fetch(zugang.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: kopf,
|
||||||
|
body: koerper,
|
||||||
|
// Keine Weiterleitungen: sonst koennte ein https-Ziel auf http umleiten.
|
||||||
|
redirect: "error",
|
||||||
|
signal: AbortSignal.timeout(15_000),
|
||||||
|
});
|
||||||
|
} catch (fehler) {
|
||||||
|
throw new ZustellFehler(`Webhook nicht erreichbar: ${fehlerBereinigen(fehler, [zugang.geheimnis])}`);
|
||||||
|
}
|
||||||
|
// Die Antwort wird nicht gelesen – sie interessiert nicht und koennte gross sein.
|
||||||
|
await antwort.body?.cancel().catch(() => undefined);
|
||||||
|
if (!antwort.ok) throw new ZustellFehler(`Webhook antwortet mit HTTP ${antwort.status}.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { MeldungsKanal } from "@/generated/prisma/enums";
|
||||||
|
import type { GespeicherteBenachrichtigung } from "@/lib/benachrichtigung/einstellungen";
|
||||||
|
import {
|
||||||
|
hostKeyNachricht,
|
||||||
|
testNachricht,
|
||||||
|
zusammenfassungLeer,
|
||||||
|
zusammenfassungNachricht,
|
||||||
|
type Nachricht,
|
||||||
|
} from "@/lib/benachrichtigung/nachrichten";
|
||||||
|
import { hostKeyGruppen, zusammenfassungAus, type Posten } from "@/lib/benachrichtigung/posten";
|
||||||
|
import {
|
||||||
|
anlassFaellig,
|
||||||
|
fingerabdruck,
|
||||||
|
naechsterVersuch,
|
||||||
|
webhookZielKurz,
|
||||||
|
type AnlassArt,
|
||||||
|
type ProtokollStand,
|
||||||
|
} from "@/lib/benachrichtigung/regeln";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { benachrichtigungLesen } from "@/server/services/benachrichtigung";
|
||||||
|
import type { Akteur } from "@/server/services/hosts";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { postenSammeln } from "./anlaesse";
|
||||||
|
import { perEmailSenden, perWebhookSenden, zugaengeLesen, ZustellFehler } from "./kanaele";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wer bekommt wann was (#028). Grundregel: kein Anlass wird ueber denselben Kanal zweimal
|
||||||
|
* gemeldet. Das sichert die Pruefung gegen das Versandprotokoll (`Zustellung`) vor jedem
|
||||||
|
* Versand – auch wenn ein Zeitplan doppelt feuert oder zwei Laeufe sich ueberschneiden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type VersandBilanz = { gesendet: number; fehlgeschlagen: number };
|
||||||
|
|
||||||
|
const ZUSAMMENFASSUNG: readonly AnlassArt[] = ["drift", "ablauf", "abgelaufen", "unerreichbar"];
|
||||||
|
|
||||||
|
function appUrl(): string {
|
||||||
|
return process.env["SSHKV_APP_URL"]?.trim() || "http://localhost:3400";
|
||||||
|
}
|
||||||
|
|
||||||
|
function aktiveKanaele(e: GespeicherteBenachrichtigung): MeldungsKanal[] {
|
||||||
|
const kanaele: MeldungsKanal[] = [];
|
||||||
|
if (e.email.aktiv) kanaele.push("EMAIL");
|
||||||
|
if (e.webhook.aktiv) kanaele.push("WEBHOOK");
|
||||||
|
return kanaele;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Posten, die ueber diesen Kanal (noch einmal) gemeldet werden duerfen. */
|
||||||
|
async function faelligFuer(
|
||||||
|
posten: readonly Posten[],
|
||||||
|
kanal: MeldungsKanal,
|
||||||
|
jetzt: Date,
|
||||||
|
nurWiederholungen: boolean,
|
||||||
|
): Promise<{ posten: Posten[]; stand: Map<string, ProtokollStand> }> {
|
||||||
|
if (posten.length === 0) return { posten: [], stand: new Map() };
|
||||||
|
const zeilen = await prisma.zustellung.findMany({
|
||||||
|
where: { kanal, anlass: { in: posten.map((p) => p.anlass) } },
|
||||||
|
select: { anlass: true, ergebnis: true, versuche: true, naechsterVersuchAm: true },
|
||||||
|
});
|
||||||
|
const stand = new Map(zeilen.map((z) => [z.anlass, z]));
|
||||||
|
return {
|
||||||
|
posten: posten.filter((p) => anlassFaellig(stand.get(p.anlass), jetzt, nurWiederholungen)),
|
||||||
|
stand,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Versandauftrag = {
|
||||||
|
readonly kanal: MeldungsKanal;
|
||||||
|
readonly einstellungen: GespeicherteBenachrichtigung;
|
||||||
|
readonly nachricht: Nachricht;
|
||||||
|
readonly anlaesse: readonly string[];
|
||||||
|
readonly stand: ReadonlyMap<string, ProtokollStand>;
|
||||||
|
readonly jetzt: Date;
|
||||||
|
readonly akteur?: Akteur;
|
||||||
|
/** Testnachrichten werden nie wiederholt – das Ergebnis sieht der Mensch sofort. */
|
||||||
|
readonly wiederholen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet eine Nachricht ueber einen Kanal und schreibt das Ergebnis je Anlass ins
|
||||||
|
* Versandprotokoll und einmal ins revisionssichere Protokoll. Liefert den Fehlertext oder null.
|
||||||
|
*/
|
||||||
|
async function sendenUndFesthalten(auftrag: Versandauftrag): Promise<string | null> {
|
||||||
|
const { kanal, einstellungen, nachricht, jetzt } = auftrag;
|
||||||
|
let fehler: string | null = null;
|
||||||
|
try {
|
||||||
|
const zugang = zugaengeLesen(einstellungen);
|
||||||
|
if (kanal === "EMAIL") await perEmailSenden(zugang.email, nachricht);
|
||||||
|
else await perWebhookSenden(zugang.webhook, nachricht);
|
||||||
|
} catch (ursache) {
|
||||||
|
// Fremde Fehler (z. B. ein nicht mehr entschluesselbares Passwort) mit fester Meldung –
|
||||||
|
// ihre Texte koennen Interna tragen.
|
||||||
|
fehler =
|
||||||
|
ursache instanceof ZustellFehler
|
||||||
|
? ursache.message
|
||||||
|
: ursache instanceof Error && ursache.name === "GeheimnisFehler"
|
||||||
|
? "Zugangsdaten nicht lesbar – bitte Passwort bzw. Geheimnis neu eingeben."
|
||||||
|
: "Interner Fehler beim Versand. Details stehen im Worker-Log.";
|
||||||
|
if (!(ursache instanceof ZustellFehler)) {
|
||||||
|
console.error(
|
||||||
|
`Benachrichtigung über ${kanal} fehlgeschlagen:`,
|
||||||
|
ursache instanceof Error ? `${ursache.name}: ${ursache.message}` : String(ursache),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendung = randomUUID();
|
||||||
|
const wiederholen = auftrag.wiederholen ?? true;
|
||||||
|
for (const anlass of auftrag.anlaesse) {
|
||||||
|
const versuche = (auftrag.stand.get(anlass)?.versuche ?? 0) + 1;
|
||||||
|
const daten = {
|
||||||
|
ergebnis: fehler ? ("FEHLGESCHLAGEN" as const) : ("GESENDET" as const),
|
||||||
|
fehler,
|
||||||
|
versuche,
|
||||||
|
naechsterVersuchAm: fehler && wiederholen ? naechsterVersuch(versuche, jetzt) : null,
|
||||||
|
sendung,
|
||||||
|
betreff: nachricht.betreff,
|
||||||
|
zeitpunkt: jetzt,
|
||||||
|
};
|
||||||
|
await prisma.zustellung.upsert({
|
||||||
|
where: { anlass_kanal: { anlass, kanal } },
|
||||||
|
create: { anlass, kanal, ...daten },
|
||||||
|
update: daten,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await protokollieren({
|
||||||
|
akteur: auftrag.akteur ?? "System",
|
||||||
|
aktion: fehler ? "benachrichtigung.fehlgeschlagen" : "benachrichtigung.gesendet",
|
||||||
|
ergebnis: fehler ? "FEHLER" : "ERFOLG",
|
||||||
|
details: {
|
||||||
|
kanal,
|
||||||
|
ereignis: nachricht.nutzlast.ereignis,
|
||||||
|
betreff: nachricht.betreff,
|
||||||
|
anlaesse: auftrag.anlaesse.length,
|
||||||
|
// Empfaenger nur gezaehlt, beim Webhook nur Schema und Host – der Pfad traegt oft ein Token.
|
||||||
|
ziel:
|
||||||
|
kanal === "EMAIL"
|
||||||
|
? `${einstellungen.email.empfaenger.length} Empfänger`
|
||||||
|
: webhookZielKurz(einstellungen.webhook.url),
|
||||||
|
sendung,
|
||||||
|
...(fehler ? { fehler } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return fehler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laeufe nacheinander: ueberschnitten sich Zusammenfassung und Takt, saehen beide denselben
|
||||||
|
* Anlass als „noch nicht gemeldet“. pg-boss feuert jeden Zeitplan nur einmal, die
|
||||||
|
* Ueberschneidung zweier Zeitplaene im selben Worker verhindert erst diese Kette.
|
||||||
|
*/
|
||||||
|
let kette: Promise<unknown> = Promise.resolve();
|
||||||
|
function nacheinander<T>(lauf: () => Promise<T>): Promise<T> {
|
||||||
|
const ergebnis = kette.then(lauf, lauf);
|
||||||
|
kette = ergebnis.catch(() => undefined);
|
||||||
|
return ergebnis;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zaehlen(bilanz: VersandBilanz, fehler: string | null): void {
|
||||||
|
if (fehler) bilanz.fehlgeschlagen += 1;
|
||||||
|
else bilanz.gesendet += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taegliche Zusammenfassung der Anlaesse (a)–(d). Mit `nurWiederholungen` (Fuenf-Minuten-Takt)
|
||||||
|
* nur das, was zuvor fehlschlug und wieder an der Reihe ist – Neues wartet auf den Termin.
|
||||||
|
*/
|
||||||
|
export function zusammenfassungVersenden(jetzt: Date = new Date(), nurWiederholungen = false) {
|
||||||
|
return nacheinander(() => zusammenfassungIntern(jetzt, nurWiederholungen));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zusammenfassungIntern(jetzt: Date, nurWiederholungen: boolean): Promise<VersandBilanz> {
|
||||||
|
const bilanz: VersandBilanz = { gesendet: 0, fehlgeschlagen: 0 };
|
||||||
|
const einstellungen = await benachrichtigungLesen();
|
||||||
|
const kanaele = aktiveKanaele(einstellungen);
|
||||||
|
const arten = ZUSAMMENFASSUNG.filter((a) => einstellungen.anlaesse[a]);
|
||||||
|
if (kanaele.length === 0 || arten.length === 0) return bilanz;
|
||||||
|
|
||||||
|
const alle = await postenSammeln(arten, jetzt);
|
||||||
|
for (const kanal of kanaele) {
|
||||||
|
const { posten, stand } = await faelligFuer(alle, kanal, jetzt, nurWiederholungen);
|
||||||
|
const zusammenfassung = zusammenfassungAus(posten);
|
||||||
|
if (zusammenfassungLeer(zusammenfassung)) continue;
|
||||||
|
const nachricht = zusammenfassungNachricht(zusammenfassung, { appUrl: appUrl(), jetzt });
|
||||||
|
zaehlen(
|
||||||
|
bilanz,
|
||||||
|
await sendenUndFesthalten({
|
||||||
|
kanal,
|
||||||
|
einstellungen,
|
||||||
|
nachricht,
|
||||||
|
anlaesse: posten.map((p) => p.anlass),
|
||||||
|
stand,
|
||||||
|
jetzt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return bilanz;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dringende Meldungen (e): geaenderte Host-Keys, je Host eine Nachricht. */
|
||||||
|
export function hostKeyMeldungenVersenden(jetzt: Date = new Date()) {
|
||||||
|
return nacheinander(() => hostKeyIntern(jetzt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostKeyIntern(jetzt: Date): Promise<VersandBilanz> {
|
||||||
|
const bilanz: VersandBilanz = { gesendet: 0, fehlgeschlagen: 0 };
|
||||||
|
const einstellungen = await benachrichtigungLesen();
|
||||||
|
const kanaele = aktiveKanaele(einstellungen);
|
||||||
|
if (kanaele.length === 0 || !einstellungen.anlaesse.hostkey) return bilanz;
|
||||||
|
|
||||||
|
const alle = await postenSammeln(["hostkey"], jetzt);
|
||||||
|
for (const kanal of kanaele) {
|
||||||
|
const { posten, stand } = await faelligFuer(alle, kanal, jetzt, false);
|
||||||
|
for (const gruppe of hostKeyGruppen(posten)) {
|
||||||
|
const nachricht = hostKeyNachricht(gruppe.aenderung, { appUrl: appUrl(), jetzt });
|
||||||
|
zaehlen(
|
||||||
|
bilanz,
|
||||||
|
await sendenUndFesthalten({
|
||||||
|
kanal,
|
||||||
|
einstellungen,
|
||||||
|
nachricht,
|
||||||
|
anlaesse: gruppe.anlaesse,
|
||||||
|
stand,
|
||||||
|
jetzt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bilanz;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Fuenf-Minuten-Takt: dringende Meldungen und faellige Wiederholungen. */
|
||||||
|
export function taktVersenden(jetzt: Date = new Date()): Promise<VersandBilanz> {
|
||||||
|
return nacheinander(async () => {
|
||||||
|
const dringend = await hostKeyIntern(jetzt);
|
||||||
|
const nachgeholt = await zusammenfassungIntern(jetzt, true);
|
||||||
|
return {
|
||||||
|
gesendet: dringend.gesendet + nachgeholt.gesendet,
|
||||||
|
fehlgeschlagen: dringend.fehlgeschlagen + nachgeholt.fehlgeschlagen,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „Testnachricht senden“ – ueber den gespeicherten Stand, auch bei ausgeschaltetem Kanal. */
|
||||||
|
export async function testVersenden(kanal: MeldungsKanal, akteur?: Akteur): Promise<string | null> {
|
||||||
|
const jetzt = new Date();
|
||||||
|
const einstellungen = await benachrichtigungLesen();
|
||||||
|
const nachricht = testNachricht(kanal, { appUrl: appUrl(), jetzt });
|
||||||
|
return sendenUndFesthalten({
|
||||||
|
kanal,
|
||||||
|
einstellungen,
|
||||||
|
nachricht,
|
||||||
|
anlaesse: [fingerabdruck.test(randomUUID())],
|
||||||
|
stand: new Map(),
|
||||||
|
jetzt,
|
||||||
|
...(akteur ? { akteur } : {}),
|
||||||
|
wiederholen: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -219,3 +219,21 @@ export async function deploymentKeySicherstellen(): Promise<string> {
|
|||||||
});
|
});
|
||||||
return neu.id;
|
return neu.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passt der Master-Key zur Datenbank? (#029) Oeffnet probeweise den Deployment-Key. Schlaegt
|
||||||
|
* das fehl, ist entweder die falsche Master-Key-Datei eingebunden oder eine Sicherung ohne den
|
||||||
|
* dazugehoerigen Master-Key zurueckgespielt worden – beides soll beim Start auffallen, nicht
|
||||||
|
* erst beim ersten Ausrollen.
|
||||||
|
*/
|
||||||
|
export async function masterKeyPruefen(deploymentKeyId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const klartext = await privateKeyLaden(deploymentKeyId);
|
||||||
|
klartext.fill(0);
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
"Der Master-Key passt nicht zur Datenbank: Der Deployment-Key lässt sich nicht entschlüsseln. " +
|
||||||
|
"Ist SSHKV_MASTER_KEY_FILE die Datei, die zu dieser Datenbank gehört (Sicherung)?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||||
import { abgleichHandler } from "@/worker/ablaeufe/abgleich";
|
import { abgleichHandler } from "@/worker/ablaeufe/abgleich";
|
||||||
|
import { benachrichtigungTestHandler } from "@/worker/ablaeufe/benachrichtigung";
|
||||||
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
||||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||||
|
import { deploymentRotationHandler } from "@/worker/ablaeufe/deploymentkey";
|
||||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||||
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
import { privateKeyAbholenHandler, privateKeyLoeschenHandler } from "@/worker/ablaeufe/privatekey";
|
||||||
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
import { schluesselErzeugenHandler, schluesselImportierenHandler } from "@/worker/ablaeufe/schluessel";
|
||||||
@@ -23,6 +25,8 @@ export const HANDLER: HandlerTabelle = {
|
|||||||
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
PRIVATE_KEY_LOESCHEN: privateKeyLoeschenHandler,
|
||||||
VERBINDUNGSTEST: verbindungstestHandler,
|
VERBINDUNGSTEST: verbindungstestHandler,
|
||||||
ABGLEICH: abgleichHandler,
|
ABGLEICH: abgleichHandler,
|
||||||
|
ROTATION: deploymentRotationHandler,
|
||||||
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
||||||
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
||||||
|
BENACHRICHTIGUNG_TEST: benachrichtigungTestHandler,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { alleHostsAbgleichen } from "@/server/services/abgleich";
|
|||||||
import {
|
import {
|
||||||
WARTESCHLANGE,
|
WARTESCHLANGE,
|
||||||
ZEITPLAN_ABGLEICH,
|
ZEITPLAN_ABGLEICH,
|
||||||
|
ZEITPLAN_MELDUNG,
|
||||||
|
ZEITPLAN_MELDUNG_TAKT,
|
||||||
warteschlangeHolen,
|
warteschlangeHolen,
|
||||||
type VorgangsJob,
|
type VorgangsJob,
|
||||||
} from "@/server/vorgaenge/warteschlange";
|
} from "@/server/vorgaenge/warteschlange";
|
||||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||||
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
||||||
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
import { taktVersenden, zusammenfassungVersenden } from "@/worker/benachrichtigung/versand";
|
||||||
|
import { deploymentKeySicherstellen, masterKeyPruefen } from "@/worker/geheimnisse";
|
||||||
import { HANDLER } from "@/worker/handler";
|
import { HANDLER } from "@/worker/handler";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,7 +23,7 @@ import { HANDLER } from "@/worker/handler";
|
|||||||
async function starten(): Promise<void> {
|
async function starten(): Promise<void> {
|
||||||
// Zuerst: ohne lesbaren Master-Key und Deployment-Key kann der Worker nichts tun, und das
|
// Zuerst: ohne lesbaren Master-Key und Deployment-Key kann der Worker nichts tun, und das
|
||||||
// soll beim Start auffallen, nicht beim ersten Vorgang.
|
// soll beim Start auffallen, nicht beim ersten Vorgang.
|
||||||
await deploymentKeySicherstellen();
|
await masterKeyPruefen(await deploymentKeySicherstellen());
|
||||||
const boss = await warteschlangeHolen();
|
const boss = await warteschlangeHolen();
|
||||||
|
|
||||||
await boss.work<VorgangsJob>(
|
await boss.work<VorgangsJob>(
|
||||||
@@ -61,7 +64,32 @@ async function starten(): Promise<void> {
|
|||||||
console.log(`Zeitplan: ${anzahl} Abgleich(e) eingereiht.`);
|
console.log(`Zeitplan: ${anzahl} Abgleich(e) eingereiht.`);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`sshkv-worker ${VERSION} bereit (Abgleich: ${zeitplan}).`);
|
// Benachrichtigungen (#028): taeglich die Zusammenfassung, alle fuenf Minuten geaenderte
|
||||||
|
// Host-Keys (dringend) und faellige Wiederholungen. Fehler nur ins Log – das
|
||||||
|
// Versandprotokoll haelt fest, was nachzuholen ist.
|
||||||
|
const meldung = process.env["SSHKV_MELDUNG_CRON"]?.trim() || "0 7 * * *";
|
||||||
|
const melden = (lauf: () => Promise<{ gesendet: number; fehlgeschlagen: number }>) => async () => {
|
||||||
|
const bilanz = await lauf().catch((fehler: unknown) => {
|
||||||
|
console.error("Benachrichtigung:", fehler instanceof Error ? fehler.message : fehler);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
if (bilanz && bilanz.gesendet + bilanz.fehlgeschlagen > 0)
|
||||||
|
console.log(`Benachrichtigung: ${bilanz.gesendet} gesendet, ${bilanz.fehlgeschlagen} fehlgeschlagen.`);
|
||||||
|
};
|
||||||
|
await boss.createQueue(ZEITPLAN_MELDUNG);
|
||||||
|
await boss.schedule(ZEITPLAN_MELDUNG, meldung, null, { tz: "Europe/Berlin" });
|
||||||
|
await boss.work(
|
||||||
|
ZEITPLAN_MELDUNG,
|
||||||
|
melden(() => zusammenfassungVersenden()),
|
||||||
|
);
|
||||||
|
await boss.createQueue(ZEITPLAN_MELDUNG_TAKT);
|
||||||
|
await boss.schedule(ZEITPLAN_MELDUNG_TAKT, "*/5 * * * *", null, { tz: "Europe/Berlin" });
|
||||||
|
await boss.work(
|
||||||
|
ZEITPLAN_MELDUNG_TAKT,
|
||||||
|
melden(() => taktVersenden()),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`sshkv-worker ${VERSION} bereit (Abgleich: ${zeitplan}, Meldungen: ${meldung}).`);
|
||||||
|
|
||||||
const beenden = async (signal: string) => {
|
const beenden = async (signal: string) => {
|
||||||
console.log(`${signal} empfangen, beende laufende Vorgänge …`);
|
console.log(`${signal} empfangen, beende laufende Vorgänge …`);
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
hostKeyNachricht,
|
||||||
|
signatur,
|
||||||
|
testNachricht,
|
||||||
|
webhookKoerper,
|
||||||
|
zusammenfassungLeer,
|
||||||
|
zusammenfassungNachricht,
|
||||||
|
} from "@/lib/benachrichtigung/nachrichten";
|
||||||
|
import { hostKeyGruppen, zusammenfassungAus, type Posten } from "@/lib/benachrichtigung/posten";
|
||||||
|
|
||||||
|
/** Nachrichtenaufbau (#028): Text- und HTML-Mail auf Deutsch, Webhook-JSON, Signatur. */
|
||||||
|
|
||||||
|
const RAHMEN = { appUrl: "https://sshkv.example.de/", jetzt: new Date("2026-10-12T05:00:00Z") };
|
||||||
|
|
||||||
|
const POSTEN: Posten[] = [
|
||||||
|
{
|
||||||
|
art: "drift",
|
||||||
|
anlass: "drift:a1",
|
||||||
|
host: { hostId: "h2", name: "srv-zeta", adresse: "10.0.0.2" },
|
||||||
|
befund: { art: "RECHTE", zielbenutzer: "root", fingerprint: null, text: "authorized_keys ist 0666" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
art: "drift",
|
||||||
|
anlass: "drift:a2",
|
||||||
|
host: { hostId: "h1", name: "srv-alpha", adresse: "10.0.0.1" },
|
||||||
|
befund: {
|
||||||
|
art: "UNBEKANNTER_SCHLUESSEL",
|
||||||
|
zielbenutzer: "deploy",
|
||||||
|
fingerprint: "SHA256:abc",
|
||||||
|
text: "Kommentar <script>alert(1)</script>",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
art: "drift",
|
||||||
|
anlass: "drift:a3",
|
||||||
|
host: { hostId: "h1", name: "srv-alpha", adresse: "10.0.0.1" },
|
||||||
|
befund: { art: "FEHLENDER_SCHLUESSEL", zielbenutzer: "deploy", fingerprint: "SHA256:def", text: null },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
art: "unerreichbar",
|
||||||
|
anlass: "unerreichbar:h3:nie",
|
||||||
|
host: {
|
||||||
|
hostId: "h3",
|
||||||
|
name: "srv-weg",
|
||||||
|
adresse: "10.0.0.3",
|
||||||
|
zuletztGeprueftAm: null,
|
||||||
|
letzterAbgleichAm: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
art: "ablauf",
|
||||||
|
anlass: "ablauf:k1:7:2026-10-17",
|
||||||
|
schluessel: {
|
||||||
|
schluesselId: "k1",
|
||||||
|
name: "Backup-Zugang",
|
||||||
|
fingerprint: "SHA256:k1",
|
||||||
|
ablaufAm: new Date("2026-10-17T10:00:00Z"),
|
||||||
|
tage: 5,
|
||||||
|
stufe: 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
art: "abgelaufen",
|
||||||
|
anlass: "abgelaufen:k2:2026-10-01",
|
||||||
|
schluessel: {
|
||||||
|
schluesselId: "k2",
|
||||||
|
name: "Alter Zugang",
|
||||||
|
fingerprint: "SHA256:k2",
|
||||||
|
ablaufAm: new Date("2026-10-01T10:00:00Z"),
|
||||||
|
hosts: ["srv-alpha", "Gruppe pve"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("Zusammenfassung", () => {
|
||||||
|
const z = zusammenfassungAus(POSTEN);
|
||||||
|
const n = zusammenfassungNachricht(z, RAHMEN);
|
||||||
|
|
||||||
|
it("fasst Drift je Host zusammen und sortiert nach Namen", () => {
|
||||||
|
expect(z.drift.map((h) => [h.name, h.befunde.length])).toEqual([
|
||||||
|
["srv-alpha", 2],
|
||||||
|
["srv-zeta", 1],
|
||||||
|
]);
|
||||||
|
expect(zusammenfassungLeer(z)).toBe(false);
|
||||||
|
expect(zusammenfassungLeer(zusammenfassungAus([]))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Betreff zählt die Anlässe auf Deutsch", () => {
|
||||||
|
expect(n.betreff).toBe(
|
||||||
|
"[sshkv] Zusammenfassung: 3 neue Abweichungen, 1 Host nicht erreichbar, 1 Schlüssel läuft ab, 1 abgelaufener Schlüssel ausgerollt",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Textfassung mit Umlauten, Links und allen Abschnitten", () => {
|
||||||
|
expect(n.text).toContain("Zusammenfassung vom 12.10.2026, 07:00");
|
||||||
|
expect(n.text).toContain("Neue Abweichungen (Drift):");
|
||||||
|
expect(n.text).toContain(" srv-alpha (10.0.0.1):");
|
||||||
|
expect(n.text).toContain("Unbekannter Schlüssel – Benutzer deploy – SHA256:abc");
|
||||||
|
expect(n.text).toContain("https://sshkv.example.de/hosts/h1");
|
||||||
|
expect(n.text).toContain("srv-weg (10.0.0.3) – noch nie erfolgreich abgeglichen");
|
||||||
|
expect(n.text).toContain("Backup-Zugang – läuft am 17.10.2026 ab (in 5 Tagen)");
|
||||||
|
expect(n.text).toContain("Alter Zugang – abgelaufen am 01.10.2026, noch auf srv-alpha, Gruppe pve");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HTML maskiert Inhalte von den Hosts", () => {
|
||||||
|
expect(n.html).toContain('<html lang="de">');
|
||||||
|
expect(n.html).not.toContain("<script>");
|
||||||
|
expect(n.html).toContain("Kommentar <script>alert(1)</script>");
|
||||||
|
expect(n.html).toContain("Bald ablaufende Schlüssel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Webhook-Nutzlast mit Ereignisart, Host, Befunden und Zeitstempel", () => {
|
||||||
|
const json = JSON.parse(webhookKoerper(n)) as Record<string, unknown>;
|
||||||
|
expect(json).toMatchObject({
|
||||||
|
ereignis: "zusammenfassung",
|
||||||
|
zeitstempel: "2026-10-12T05:00:00.000Z",
|
||||||
|
instanz: "https://sshkv.example.de",
|
||||||
|
betreff: n.betreff,
|
||||||
|
});
|
||||||
|
expect((json["drift"] as unknown[])[0]).toEqual({
|
||||||
|
host: { id: "h1", name: "srv-alpha", adresse: "10.0.0.1" },
|
||||||
|
befunde: [
|
||||||
|
{
|
||||||
|
art: "UNBEKANNTER_SCHLUESSEL",
|
||||||
|
artText: "Unbekannter Schlüssel",
|
||||||
|
zielbenutzer: "deploy",
|
||||||
|
fingerprint: "SHA256:abc",
|
||||||
|
text: "Kommentar <script>alert(1)</script>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
art: "FEHLENDER_SCHLUESSEL",
|
||||||
|
artText: "Schlüssel fehlt",
|
||||||
|
zielbenutzer: "deploy",
|
||||||
|
fingerprint: "SHA256:def",
|
||||||
|
text: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(json["ablauf"]).toEqual([
|
||||||
|
{
|
||||||
|
schluessel: { id: "k1", name: "Backup-Zugang", fingerprint: "SHA256:k1" },
|
||||||
|
ablaufAm: "2026-10-17T10:00:00.000Z",
|
||||||
|
tage: 5,
|
||||||
|
stufe: 7,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Host-Key geändert (dringend)", () => {
|
||||||
|
const gruppen = hostKeyGruppen([
|
||||||
|
{
|
||||||
|
art: "hostkey",
|
||||||
|
anlass: "hostkey:x1",
|
||||||
|
host: { hostId: "h1", name: "srv-alpha", adresse: "10.0.0.1" },
|
||||||
|
befund: { algorithmus: "ssh-ed25519", erwartet: "SHA256:alt", gesehen: "SHA256:neu" },
|
||||||
|
},
|
||||||
|
...POSTEN,
|
||||||
|
]);
|
||||||
|
|
||||||
|
it("je Host eine Nachricht, nur aus Host-Key-Posten", () => {
|
||||||
|
expect(gruppen).toHaveLength(1);
|
||||||
|
expect(gruppen[0]?.anlaesse).toEqual(["hostkey:x1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Betreff, Text und Nutzlast", () => {
|
||||||
|
const n = hostKeyNachricht(gruppen[0]!.aenderung, RAHMEN);
|
||||||
|
expect(n.betreff).toBe("[sshkv] DRINGEND: Host-Key von srv-alpha hat sich geändert");
|
||||||
|
expect(n.text).toContain("ssh-ed25519: erwartet SHA256:alt, gesehen SHA256:neu");
|
||||||
|
expect(n.text).toContain("bestätigt");
|
||||||
|
expect(n.html).toContain("Dringend");
|
||||||
|
expect(n.nutzlast).toMatchObject({
|
||||||
|
ereignis: "hostkey.geaendert",
|
||||||
|
dringend: true,
|
||||||
|
host: { id: "h1", name: "srv-alpha" },
|
||||||
|
befunde: [{ art: "HOSTKEY_GEAENDERT", erwartet: "SHA256:alt", fingerprint: "SHA256:neu" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Testnachricht und Signatur", () => {
|
||||||
|
it("Testnachricht nennt den Kanal", () => {
|
||||||
|
const n = testNachricht("EMAIL", RAHMEN);
|
||||||
|
expect(n.betreff).toBe("[sshkv] Testnachricht");
|
||||||
|
expect(n.text).toContain("per E-Mail funktioniert");
|
||||||
|
expect(n.nutzlast.ereignis).toBe("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("HMAC-SHA256 über den rohen Body", () => {
|
||||||
|
const koerper = webhookKoerper(testNachricht("WEBHOOK", RAHMEN));
|
||||||
|
const erwartet = createHmac("sha256", "geheim").update(koerper).digest("hex");
|
||||||
|
expect(signatur(koerper, "geheim")).toBe(`sha256=${erwartet}`);
|
||||||
|
expect(signatur(`${koerper} `, "geheim")).not.toBe(`sha256=${erwartet}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
benachrichtigungSchema,
|
||||||
|
empfaengerAus,
|
||||||
|
gespeichertLesen,
|
||||||
|
alsAnsicht,
|
||||||
|
STANDARD_BENACHRICHTIGUNG,
|
||||||
|
} from "@/lib/benachrichtigung/einstellungen";
|
||||||
|
import {
|
||||||
|
ablaufStufe,
|
||||||
|
anlassFaellig,
|
||||||
|
cronBeschreiben,
|
||||||
|
fingerabdruck,
|
||||||
|
MAX_VERSUCHE,
|
||||||
|
naechsterVersuch,
|
||||||
|
webhookUrlPruefen,
|
||||||
|
webhookZielKurz,
|
||||||
|
} from "@/lib/benachrichtigung/regeln";
|
||||||
|
|
||||||
|
/** Regeln der Benachrichtigungen (#028): Stufen, Fingerabdruecke, Wiederholungen, Pruefungen. */
|
||||||
|
|
||||||
|
// 10:00 Uhr Berliner Zeit – weit weg von Mitternacht, damit Kalendertage eindeutig sind.
|
||||||
|
const JETZT = new Date("2026-10-12T08:00:00Z");
|
||||||
|
const inTagen = (tage: number) => new Date(JETZT.getTime() + tage * 86_400_000);
|
||||||
|
|
||||||
|
describe("Ablaufstufen 30/14/7", () => {
|
||||||
|
it.each([
|
||||||
|
[45, null],
|
||||||
|
[31, null],
|
||||||
|
[30, 30],
|
||||||
|
[20, 30],
|
||||||
|
[15, 30],
|
||||||
|
[14, 14],
|
||||||
|
[8, 14],
|
||||||
|
[7, 7],
|
||||||
|
[1, 7],
|
||||||
|
])("in %i Tagen → Stufe %s", (tage, stufe) => {
|
||||||
|
expect(ablaufStufe(inTagen(tage), JETZT)).toBe(stufe);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abgelaufene Schlüssel haben keine Stufe mehr", () => {
|
||||||
|
expect(ablaufStufe(JETZT, JETZT)).toBeNull();
|
||||||
|
expect(ablaufStufe(inTagen(-3), JETZT)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("je Stufe ein eigener Fingerabdruck, ein verlängerter Schlüssel beginnt von vorn", () => {
|
||||||
|
const ablauf = inTagen(10);
|
||||||
|
const a30 = fingerabdruck.ablauf("k1", 30, ablauf);
|
||||||
|
const a14 = fingerabdruck.ablauf("k1", 14, ablauf);
|
||||||
|
expect(a30).toBe("ablauf:k1:30:2026-10-22");
|
||||||
|
expect(a14).not.toBe(a30);
|
||||||
|
expect(fingerabdruck.ablauf("k1", 14, inTagen(40))).not.toBe(a14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Unerreichbarkeit: ein Fingerabdruck je Ausfall", () => {
|
||||||
|
const vorher = new Date("2026-10-10T00:30:00Z");
|
||||||
|
expect(fingerabdruck.unerreichbar("h1", vorher)).toBe(fingerabdruck.unerreichbar("h1", vorher));
|
||||||
|
expect(fingerabdruck.unerreichbar("h1", null)).toBe("unerreichbar:h1:nie");
|
||||||
|
expect(fingerabdruck.unerreichbar("h1", JETZT)).not.toBe(fingerabdruck.unerreichbar("h1", vorher));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Deduplizierung und Wiederholung", () => {
|
||||||
|
it("noch nie gemeldet: fällig, außer in einer reinen Wiederholungsrunde", () => {
|
||||||
|
expect(anlassFaellig(undefined, JETZT)).toBe(true);
|
||||||
|
expect(anlassFaellig(undefined, JETZT, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erfolgreich gemeldet: nie wieder", () => {
|
||||||
|
const stand = { ergebnis: "GESENDET" as const, versuche: 1, naechsterVersuchAm: null };
|
||||||
|
expect(anlassFaellig(stand, JETZT)).toBe(false);
|
||||||
|
expect(anlassFaellig(stand, JETZT, true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fehlgeschlagen: erst nach der Wartezeit und begrenzt", () => {
|
||||||
|
const spaeter = { ergebnis: "FEHLGESCHLAGEN" as const, versuche: 1, naechsterVersuchAm: inTagen(1) };
|
||||||
|
expect(anlassFaellig(spaeter, JETZT)).toBe(false);
|
||||||
|
const faellig = { ...spaeter, naechsterVersuchAm: inTagen(-1) };
|
||||||
|
expect(anlassFaellig(faellig, JETZT, true)).toBe(true);
|
||||||
|
expect(anlassFaellig({ ...faellig, versuche: MAX_VERSUCHE }, JETZT)).toBe(false);
|
||||||
|
expect(anlassFaellig({ ...faellig, naechsterVersuchAm: null }, JETZT)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Wartezeit verdoppelt sich, nach dem letzten Versuch ist Schluss", () => {
|
||||||
|
const minuten = (v: number) => ((naechsterVersuch(v, JETZT)?.getTime() ?? 0) - JETZT.getTime()) / 60_000;
|
||||||
|
expect([1, 2, 3, 4].map(minuten)).toEqual([5, 10, 20, 40]);
|
||||||
|
expect(naechsterVersuch(MAX_VERSUCHE, JETZT)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Webhook-Ziel", () => {
|
||||||
|
it("nur https, http nur ausdrücklich", () => {
|
||||||
|
expect(webhookUrlPruefen("https://hooks.example.de/x", false)).toBeNull();
|
||||||
|
expect(webhookUrlPruefen("http://10.0.0.5/hook", false)).toMatch(/Nur https/);
|
||||||
|
expect(webhookUrlPruefen("http://10.0.0.5/hook", true)).toBeNull();
|
||||||
|
expect(webhookUrlPruefen("ftp://example.de", true)).toMatch(/Nur https/);
|
||||||
|
expect(webhookUrlPruefen("kein url", false)).toMatch(/gültige/);
|
||||||
|
expect(webhookUrlPruefen("https://a:b@example.de", false)).toMatch(/Zugangsdaten/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Kurzform ohne Pfad und Query", () => {
|
||||||
|
expect(webhookZielKurz("https://hooks.example.de:8443/geheim/token?x=1")).toBe(
|
||||||
|
"https://hooks.example.de:8443",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Zeitpläne lesbar", () => {
|
||||||
|
it.each([
|
||||||
|
["30 2 * * *", "täglich um 02:30 Uhr"],
|
||||||
|
["0 7 * * *", "täglich um 07:00 Uhr"],
|
||||||
|
["0 7 * * 1-5", "werktags um 07:00 Uhr"],
|
||||||
|
["*/5 * * * *", "alle 5 Minuten"],
|
||||||
|
["0 7 1 * *", null],
|
||||||
|
["quatsch", null],
|
||||||
|
])("%s → %s", (ausdruck, text) => {
|
||||||
|
expect(cronBeschreiben(ausdruck)).toBe(text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Einstellungen", () => {
|
||||||
|
const gueltig = {
|
||||||
|
emailAktiv: true,
|
||||||
|
emailServer: "smtp.office365.com",
|
||||||
|
emailPort: "587",
|
||||||
|
emailSicherheit: "STARTTLS" as const,
|
||||||
|
emailAbsender: "sshkv@example.de",
|
||||||
|
emailBenutzer: "sshkv@example.de",
|
||||||
|
emailPasswortEntfernen: false,
|
||||||
|
emailEmpfaenger: ["a@example.de"],
|
||||||
|
webhookAktiv: false,
|
||||||
|
webhookUrl: "",
|
||||||
|
webhookHttpErlaubt: false,
|
||||||
|
webhookGeheimnisEntfernen: false,
|
||||||
|
anlaesse: ["drift" as const],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("Vorbelegung für Microsoft 365", () => {
|
||||||
|
expect(STANDARD_BENACHRICHTIGUNG.email).toMatchObject({
|
||||||
|
server: "smtp.office365.com",
|
||||||
|
port: 587,
|
||||||
|
sicherheit: "STARTTLS",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Empfängerliste aus Zeilen und Kommas, ohne Dubletten", () => {
|
||||||
|
expect(empfaengerAus("a@x.de, b@x.de\n\nb@x.de;c@x.de")).toEqual(["a@x.de", "b@x.de", "c@x.de"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prüft Pflichtfelder nur für eingeschaltete Kanäle", () => {
|
||||||
|
expect(benachrichtigungSchema.safeParse(gueltig).success).toBe(true);
|
||||||
|
const ohne = benachrichtigungSchema.safeParse({ ...gueltig, emailEmpfaenger: [] });
|
||||||
|
expect(ohne.success).toBe(false);
|
||||||
|
expect(
|
||||||
|
benachrichtigungSchema.safeParse({
|
||||||
|
...gueltig,
|
||||||
|
emailAktiv: false,
|
||||||
|
emailEmpfaenger: [],
|
||||||
|
emailAbsender: "",
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lehnt http-Webhooks ohne Häkchen und falsche Adressen ab", () => {
|
||||||
|
const http = benachrichtigungSchema.safeParse({
|
||||||
|
...gueltig,
|
||||||
|
webhookAktiv: true,
|
||||||
|
webhookUrl: "http://10.0.0.1/",
|
||||||
|
});
|
||||||
|
expect(http.success).toBe(false);
|
||||||
|
expect(http.error?.issues[0]?.path).toEqual(["webhookUrl"]);
|
||||||
|
expect(
|
||||||
|
benachrichtigungSchema.safeParse({
|
||||||
|
...gueltig,
|
||||||
|
webhookAktiv: true,
|
||||||
|
webhookUrl: "http://10.0.0.1/",
|
||||||
|
webhookHttpErlaubt: true,
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
const falsch = benachrichtigungSchema.safeParse({ ...gueltig, emailEmpfaenger: ["kein-adresse"] });
|
||||||
|
expect(falsch.error?.issues[0]?.message).toMatch(/keine gültige E-Mail-Adresse/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("die Ansicht kennt nur „gesetzt“, nie den Wert", () => {
|
||||||
|
const gespeichert = gespeichertLesen({
|
||||||
|
email: { passwort: "v1.chiffrat" },
|
||||||
|
webhook: { geheimnis: "v1.anderes" },
|
||||||
|
});
|
||||||
|
const ansicht = alsAnsicht(gespeichert);
|
||||||
|
expect(ansicht.email.passwortGesetzt).toBe(true);
|
||||||
|
expect(ansicht.webhook.geheimnisGesetzt).toBe(true);
|
||||||
|
expect(JSON.stringify(ansicht)).not.toMatch(/chiffrat|anderes/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Nachricht } from "@/lib/benachrichtigung/nachrichten";
|
||||||
|
import { EINSTELLUNG_BENACHRICHTIGUNG } from "@/lib/benachrichtigung/einstellungen";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { entschluesseln } from "@/lib/geheimnisse";
|
||||||
|
import { einstellungenSeite } from "@/server/queries/einstellungen";
|
||||||
|
import {
|
||||||
|
benachrichtigungLesen,
|
||||||
|
benachrichtigungSpeichern,
|
||||||
|
testAnfordern,
|
||||||
|
} from "@/server/services/benachrichtigung";
|
||||||
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
|
import {
|
||||||
|
hostKeyMeldungenVersenden,
|
||||||
|
taktVersenden,
|
||||||
|
testVersenden,
|
||||||
|
zusammenfassungVersenden,
|
||||||
|
} from "@/worker/benachrichtigung/versand";
|
||||||
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
|
import { warteschlangeStoppen } from "./hilfen/vorgang";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benachrichtigungen gegen die Testdatenbank (#028): Einstellungen mit verschluesselten
|
||||||
|
* Geheimnissen und das Versandprotokoll gegen Doppelmeldungen. Die Versandwege selbst sind
|
||||||
|
* hier ersetzt – gegen Mailpit und einen echten HTTP-Empfaenger laeuft
|
||||||
|
* tests/integration/benachrichtigung.test.ts.
|
||||||
|
*/
|
||||||
|
const versand = vi.hoisted(() => ({
|
||||||
|
gesendet: [] as { kanal: string; nachricht: Nachricht }[],
|
||||||
|
fehler: null as string | null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/worker/benachrichtigung/kanaele", async (original) => {
|
||||||
|
const echt = await original<typeof import("@/worker/benachrichtigung/kanaele")>();
|
||||||
|
const senden = (kanal: string) =>
|
||||||
|
vi.fn(async (_zugang: unknown, nachricht: Nachricht) => {
|
||||||
|
if (versand.fehler) throw new echt.ZustellFehler(versand.fehler);
|
||||||
|
versand.gesendet.push({ kanal, nachricht });
|
||||||
|
});
|
||||||
|
return { ...echt, perEmailSenden: senden("EMAIL"), perWebhookSenden: senden("WEBHOOK") };
|
||||||
|
});
|
||||||
|
|
||||||
|
const AKTEUR = { id: "admin", name: "Ada Admin" };
|
||||||
|
const PASSWORT = "Sehr-Geheim-SMTP-42";
|
||||||
|
const GEHEIMNIS = "webhook-hmac-geheimnis-17";
|
||||||
|
|
||||||
|
const EINGABE = {
|
||||||
|
emailAktiv: true,
|
||||||
|
emailServer: "smtp.office365.com",
|
||||||
|
emailPort: "587",
|
||||||
|
emailSicherheit: "STARTTLS" as const,
|
||||||
|
emailAbsender: "sshkv@example.de",
|
||||||
|
emailBenutzer: "sshkv@example.de",
|
||||||
|
emailPasswort: PASSWORT,
|
||||||
|
emailPasswortEntfernen: false,
|
||||||
|
emailEmpfaenger: ["admin@example.de"],
|
||||||
|
webhookAktiv: false,
|
||||||
|
webhookUrl: "https://hooks.example.de/geheimer-pfad",
|
||||||
|
webhookHttpErlaubt: false,
|
||||||
|
webhookGeheimnis: GEHEIMNIS,
|
||||||
|
webhookGeheimnisEntfernen: false,
|
||||||
|
anlaesse: ["drift", "ablauf", "abgelaufen", "unerreichbar", "hostkey"] as (
|
||||||
|
"drift" | "ablauf" | "abgelaufen" | "unerreichbar" | "hostkey"
|
||||||
|
)[],
|
||||||
|
};
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await warteschlangeStoppen();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
versand.gesendet = [];
|
||||||
|
versand.fehler = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function alles(): Promise<string> {
|
||||||
|
const teile = await Promise.all([
|
||||||
|
prisma.einstellung.findMany(),
|
||||||
|
prisma.protokoll.findMany(),
|
||||||
|
prisma.zustellung.findMany(),
|
||||||
|
prisma.vorgang.findMany(),
|
||||||
|
]);
|
||||||
|
return JSON.stringify(teile, (_k, w: unknown) => (typeof w === "bigint" ? w.toString() : w));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Einstellungen", () => {
|
||||||
|
it("speichert Passwort und Geheimnis nur verschlüsselt", async () => {
|
||||||
|
await benachrichtigungSpeichern(EINGABE, AKTEUR);
|
||||||
|
const gespeichert = await benachrichtigungLesen();
|
||||||
|
expect(gespeichert.email.passwort).toMatch(/^v1\./);
|
||||||
|
expect(entschluesseln(gespeichert.email.passwort!)).toBe(PASSWORT);
|
||||||
|
expect(entschluesseln(gespeichert.webhook.geheimnis!)).toBe(GEHEIMNIS);
|
||||||
|
const text = await alles();
|
||||||
|
expect(text).not.toContain(PASSWORT);
|
||||||
|
expect(text).not.toContain(GEHEIMNIS);
|
||||||
|
const protokoll = await prisma.protokoll.findFirstOrThrow({
|
||||||
|
where: { aktion: "einstellungen.benachrichtigungen.geaendert" },
|
||||||
|
});
|
||||||
|
expect(protokoll.details).toMatchObject({ smtpZugangGeaendert: true, signaturSchluesselGeaendert: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("die Seite liefert nie Klartext oder Chiffrat", async () => {
|
||||||
|
await benachrichtigungSpeichern(EINGABE, AKTEUR);
|
||||||
|
const gespeichert = await benachrichtigungLesen();
|
||||||
|
const seite = JSON.stringify(await einstellungenSeite());
|
||||||
|
expect(seite).not.toContain(PASSWORT);
|
||||||
|
expect(seite).not.toContain(GEHEIMNIS);
|
||||||
|
expect(seite).not.toContain(gespeichert.email.passwort!);
|
||||||
|
expect(seite).not.toContain(gespeichert.webhook.geheimnis!);
|
||||||
|
expect(seite).toContain('"passwortGesetzt":true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leeres Feld behält, Häkchen entfernt", async () => {
|
||||||
|
await benachrichtigungSpeichern(EINGABE, AKTEUR);
|
||||||
|
const vorher = await benachrichtigungLesen();
|
||||||
|
await benachrichtigungSpeichern(
|
||||||
|
{ ...EINGABE, emailPasswort: undefined, webhookGeheimnis: undefined },
|
||||||
|
AKTEUR,
|
||||||
|
);
|
||||||
|
const behalten = await benachrichtigungLesen();
|
||||||
|
expect(behalten.email.passwort).toBe(vorher.email.passwort);
|
||||||
|
expect(behalten.webhook.geheimnis).toBe(vorher.webhook.geheimnis);
|
||||||
|
await benachrichtigungSpeichern(
|
||||||
|
{ ...EINGABE, emailPasswort: undefined, webhookGeheimnis: undefined, emailPasswortEntfernen: true },
|
||||||
|
AKTEUR,
|
||||||
|
);
|
||||||
|
const entfernt = await benachrichtigungLesen();
|
||||||
|
expect(entfernt.email.passwort).toBeNull();
|
||||||
|
expect(entfernt.webhook.geheimnis).toBe(vorher.webhook.geheimnis);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ungültige Eingaben werden zu Feldfehlern", async () => {
|
||||||
|
const fehler = await benachrichtigungSpeichern(
|
||||||
|
{ ...EINGABE, webhookAktiv: true, webhookUrl: "http://intern/hook" },
|
||||||
|
AKTEUR,
|
||||||
|
).catch((e: unknown) => e);
|
||||||
|
expect(fehler).toBeInstanceOf(FachFehler);
|
||||||
|
expect((fehler as FachFehler).feldfehler["webhookUrl"]).toMatch(/Nur https/);
|
||||||
|
expect(await prisma.einstellung.count({ where: { schluessel: EINSTELLUNG_BENACHRICHTIGUNG } })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Testnachricht nur für vollständig eingetragene Kanäle", async () => {
|
||||||
|
const admin = await prisma.benutzer.create({ data: { name: "Ada Admin", rolle: "ADMIN" } });
|
||||||
|
await expect(testAnfordern("EMAIL", admin.id)).rejects.toThrow(/Erst Server/);
|
||||||
|
await benachrichtigungSpeichern(EINGABE, AKTEUR);
|
||||||
|
const id = await testAnfordern("EMAIL", admin.id);
|
||||||
|
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id } });
|
||||||
|
expect(vorgang).toMatchObject({ art: "BENACHRICHTIGUNG_TEST", eingabe: { kanal: "EMAIL" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Versandprotokoll
|
||||||
|
|
||||||
|
const JETZT = new Date("2026-10-12T05:00:00Z");
|
||||||
|
const spaeter = (minuten: number) => new Date(JETZT.getTime() + minuten * 60_000);
|
||||||
|
let zaehler = 0;
|
||||||
|
|
||||||
|
async function hostMitDrift() {
|
||||||
|
zaehler += 1;
|
||||||
|
const host = await prisma.host.create({
|
||||||
|
data: { name: `srv-${zaehler}`, adresse: `10.0.0.${zaehler}`, bootstrapAm: JETZT, erreichbar: true },
|
||||||
|
});
|
||||||
|
const abweichung = await prisma.abweichung.create({
|
||||||
|
data: {
|
||||||
|
hostId: host.id,
|
||||||
|
art: "UNBEKANNTER_SCHLUESSEL",
|
||||||
|
zielbenutzer: "root",
|
||||||
|
details: { text: "fremd" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { host, abweichung };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function schluessel(ablaufAm: Date) {
|
||||||
|
zaehler += 1;
|
||||||
|
return prisma.schluessel.create({
|
||||||
|
data: {
|
||||||
|
name: `Schlüssel ${zaehler}`,
|
||||||
|
slug: `s-${zaehler}`,
|
||||||
|
typ: "ED25519",
|
||||||
|
kategorie: "DIENST",
|
||||||
|
publicKey: "ssh-ed25519 AAAA test",
|
||||||
|
fingerprint: `SHA256:t${zaehler}`,
|
||||||
|
kommentar: "t",
|
||||||
|
ablaufAm,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Versandprotokoll", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: true }, AKTEUR);
|
||||||
|
versand.gesendet = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet eine Abweichung je Kanal genau einmal", async () => {
|
||||||
|
const { abweichung } = await hostMitDrift();
|
||||||
|
expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 2, fehlgeschlagen: 0 });
|
||||||
|
expect(versand.gesendet.map((g) => g.kanal).sort()).toEqual(["EMAIL", "WEBHOOK"]);
|
||||||
|
expect(await zusammenfassungVersenden(spaeter(60))).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||||
|
|
||||||
|
const zeilen = await prisma.zustellung.findMany({ where: { anlass: `drift:${abweichung.id}` } });
|
||||||
|
expect(zeilen.map((z) => z.ergebnis)).toEqual(["GESENDET", "GESENDET"]);
|
||||||
|
const protokoll = await prisma.protokoll.findMany({ where: { aktion: "benachrichtigung.gesendet" } });
|
||||||
|
expect(protokoll).toHaveLength(2);
|
||||||
|
expect(JSON.stringify(protokoll, (_k, w: unknown) => (typeof w === "bigint" ? 0 : w))).not.toContain(
|
||||||
|
"geheimer-pfad",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("eine neue Abweichung kommt in die nächste Zusammenfassung, die alte nicht noch einmal", async () => {
|
||||||
|
await hostMitDrift();
|
||||||
|
await zusammenfassungVersenden(JETZT);
|
||||||
|
versand.gesendet = [];
|
||||||
|
const { host } = await hostMitDrift();
|
||||||
|
await zusammenfassungVersenden(spaeter(24 * 60));
|
||||||
|
const email = versand.gesendet.find((g) => g.kanal === "EMAIL")!.nachricht;
|
||||||
|
expect(email.betreff).toContain("1 neue Abweichung");
|
||||||
|
expect(email.text).toContain(host.name);
|
||||||
|
expect(email.text).not.toContain("srv-1 ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wiederholt Fehlschläge begrenzt und nur nach der Wartezeit", async () => {
|
||||||
|
await hostMitDrift();
|
||||||
|
versand.fehler = "SMTP: 535 Authentication unsuccessful";
|
||||||
|
expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 0, fehlgeschlagen: 2 });
|
||||||
|
const zeile = await prisma.zustellung.findFirstOrThrow({ where: { kanal: "EMAIL" } });
|
||||||
|
expect(zeile).toMatchObject({
|
||||||
|
ergebnis: "FEHLGESCHLAGEN",
|
||||||
|
versuche: 1,
|
||||||
|
fehler: "SMTP: 535 Authentication unsuccessful",
|
||||||
|
});
|
||||||
|
expect(zeile.naechsterVersuchAm).toEqual(spaeter(5));
|
||||||
|
expect(await prisma.protokoll.count({ where: { aktion: "benachrichtigung.fehlgeschlagen" } })).toBe(2);
|
||||||
|
|
||||||
|
// Vor Ablauf der Wartezeit nichts; danach holt der Takt nach.
|
||||||
|
expect(await taktVersenden(spaeter(2))).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||||
|
versand.fehler = null;
|
||||||
|
expect(await taktVersenden(spaeter(6))).toEqual({ gesendet: 2, fehlgeschlagen: 0 });
|
||||||
|
expect(await prisma.zustellung.findFirstOrThrow({ where: { kanal: "EMAIL" } })).toMatchObject({
|
||||||
|
ergebnis: "GESENDET",
|
||||||
|
versuche: 2,
|
||||||
|
naechsterVersuchAm: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der Takt verschickt nichts Neues – das wartet auf die Zusammenfassung.
|
||||||
|
await hostMitDrift();
|
||||||
|
expect(await taktVersenden(spaeter(20))).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt nach fünf Versuchen auf", async () => {
|
||||||
|
await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false }, AKTEUR);
|
||||||
|
await hostMitDrift();
|
||||||
|
versand.fehler = "kaputt";
|
||||||
|
await zusammenfassungVersenden(JETZT);
|
||||||
|
for (const minuten of [10, 30, 60, 120, 240, 480]) await taktVersenden(spaeter(minuten));
|
||||||
|
const zeile = await prisma.zustellung.findFirstOrThrow();
|
||||||
|
expect(zeile).toMatchObject({ versuche: 5, naechsterVersuchAm: null, ergebnis: "FEHLGESCHLAGEN" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ablauf: je Stufe genau eine Meldung", async () => {
|
||||||
|
await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false }, AKTEUR);
|
||||||
|
const tag = 24 * 60;
|
||||||
|
await schluessel(spaeter(20 * tag));
|
||||||
|
await zusammenfassungVersenden(JETZT);
|
||||||
|
await zusammenfassungVersenden(spaeter(1 * tag));
|
||||||
|
await zusammenfassungVersenden(spaeter(7 * tag)); // noch 13 Tage → Stufe 14
|
||||||
|
await zusammenfassungVersenden(spaeter(8 * tag));
|
||||||
|
await zusammenfassungVersenden(spaeter(14 * tag)); // noch 6 Tage → Stufe 7
|
||||||
|
await zusammenfassungVersenden(spaeter(19 * tag));
|
||||||
|
const anlaesse = (await prisma.zustellung.findMany({ orderBy: { zeitpunkt: "asc" } })).map((z) =>
|
||||||
|
z.anlass.split(":").slice(0, 3).join(":"),
|
||||||
|
);
|
||||||
|
expect(anlaesse).toEqual([
|
||||||
|
expect.stringMatching(/^ablauf:.+:30$/),
|
||||||
|
expect.stringMatching(/^ablauf:.+:14$/),
|
||||||
|
expect.stringMatching(/^ablauf:.+:7$/),
|
||||||
|
]);
|
||||||
|
expect(versand.gesendet).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("abgelaufen und noch ausgerollt; unerreichbar einmal je Ausfall", async () => {
|
||||||
|
await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false }, AKTEUR);
|
||||||
|
const host = await prisma.host.create({
|
||||||
|
data: { name: "srv-weg", adresse: "10.9.9.9", bootstrapAm: JETZT, erreichbar: false },
|
||||||
|
});
|
||||||
|
const alt = await schluessel(spaeter(-60));
|
||||||
|
await prisma.publicVerwendung.create({
|
||||||
|
data: { schluesselId: alt.id, hostId: host.id, zielbenutzer: "root", zustand: "AUSGEROLLT" },
|
||||||
|
});
|
||||||
|
await zusammenfassungVersenden(JETZT);
|
||||||
|
const text = versand.gesendet[0]!.nachricht.text;
|
||||||
|
expect(text).toContain("Hosts nicht erreichbar:");
|
||||||
|
expect(text).toContain("srv-weg (10.9.9.9) – noch nie erfolgreich abgeglichen");
|
||||||
|
expect(text).toContain(`${alt.name} – abgelaufen am 12.10.2026, noch auf srv-weg`);
|
||||||
|
|
||||||
|
versand.gesendet = [];
|
||||||
|
await zusammenfassungVersenden(spaeter(24 * 60));
|
||||||
|
expect(versand.gesendet).toHaveLength(0);
|
||||||
|
|
||||||
|
// Erholt und wieder ausgefallen: neuer Ausfall, neue Meldung.
|
||||||
|
await prisma.host.update({ where: { id: host.id }, data: { letzterAbgleichAm: spaeter(25 * 60) } });
|
||||||
|
await zusammenfassungVersenden(spaeter(48 * 60));
|
||||||
|
expect(versand.gesendet).toHaveLength(1);
|
||||||
|
expect(versand.gesendet[0]!.nachricht.betreff).toContain("1 Host nicht erreichbar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ausgeschaltete Anlässe werden nicht gemeldet", async () => {
|
||||||
|
await benachrichtigungSpeichern({ ...EINGABE, webhookAktiv: false, anlaesse: ["ablauf"] }, AKTEUR);
|
||||||
|
await hostMitDrift();
|
||||||
|
expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geänderter Host-Key: sofort im Takt, nicht in der Zusammenfassung, nicht als unerreichbar", async () => {
|
||||||
|
const host = await prisma.host.create({
|
||||||
|
data: { name: "srv-mitm", adresse: "10.6.6.6", bootstrapAm: JETZT, erreichbar: false },
|
||||||
|
});
|
||||||
|
await prisma.abweichung.create({
|
||||||
|
data: {
|
||||||
|
hostId: host.id,
|
||||||
|
art: "HOSTKEY_GEAENDERT",
|
||||||
|
fingerprint: "SHA256:neu",
|
||||||
|
details: { erwartet: "SHA256:alt", algorithmus: "ssh-ed25519" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await zusammenfassungVersenden(JETZT)).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||||
|
expect(await taktVersenden(JETZT)).toEqual({ gesendet: 2, fehlgeschlagen: 0 });
|
||||||
|
expect(versand.gesendet[0]!.nachricht.betreff).toBe(
|
||||||
|
"[sshkv] DRINGEND: Host-Key von srv-mitm hat sich geändert",
|
||||||
|
);
|
||||||
|
expect(await hostKeyMeldungenVersenden(spaeter(5))).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Testnachrichten landen im Protokoll und werden nicht wiederholt", async () => {
|
||||||
|
versand.fehler = "Webhook antwortet mit HTTP 500.";
|
||||||
|
expect(await testVersenden("WEBHOOK", AKTEUR)).toBe("Webhook antwortet mit HTTP 500.");
|
||||||
|
const zeile = await prisma.zustellung.findFirstOrThrow();
|
||||||
|
expect(zeile.anlass).toMatch(/^test:/);
|
||||||
|
expect(zeile.naechsterVersuchAm).toBeNull();
|
||||||
|
const protokoll = await prisma.protokoll.findFirstOrThrow({
|
||||||
|
where: { aktion: "benachrichtigung.fehlgeschlagen" },
|
||||||
|
});
|
||||||
|
expect(protokoll).toMatchObject({
|
||||||
|
akteurName: "Ada Admin",
|
||||||
|
details: { ziel: "https://hooks.example.de" },
|
||||||
|
});
|
||||||
|
const seite = await einstellungenSeite();
|
||||||
|
expect(seite.zustellungen[0]).toMatchObject({
|
||||||
|
kanal: "WEBHOOK",
|
||||||
|
ergebnis: "FEHLGESCHLAGEN",
|
||||||
|
anlaesse: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,7 +46,10 @@ test("ohne Sitzung geht es zur Anmeldung, mit Ziel", async ({ page }) => {
|
|||||||
test("OIDC-Admin landet in der Übersicht und kann sich abmelden", async ({ page }) => {
|
test("OIDC-Admin landet in der Übersicht und kann sich abmelden", async ({ page }) => {
|
||||||
await oidcAnmelden(page, "admin");
|
await oidcAnmelden(page, "admin");
|
||||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||||
await expect(page.getByText("Admin (Mock)")).toBeVisible();
|
// Der Name steht seit #026 auch in den letzten Aktionen – gezielt im Benutzerkasten pruefen.
|
||||||
|
await expect(
|
||||||
|
page.getByText("Admin · über Mock-OIDC").locator("..").getByText("Admin (Mock)", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
await expect(page.getByText("Admin · über Mock-OIDC")).toBeVisible();
|
await expect(page.getByText("Admin · über Mock-OIDC")).toBeVisible();
|
||||||
await page.getByRole("button", { name: "Abmelden" }).click();
|
await page.getByRole("button", { name: "Abmelden" }).click();
|
||||||
await expect(page.getByText("Sie wurden abgemeldet.")).toBeVisible();
|
await expect(page.getByText("Sie wurden abgemeldet.")).toBeVisible();
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { anmelden, archivieren, aufZiel, hostEinrichten, PORT, zieleZuruecksetzen } from "./hilfen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation des Deployment-Keys im Browser (#031), gebauter Stand + Worker: zwei frisch
|
||||||
|
* eingerichtete Hosts, fuenf Schritte, danach meldet sich sshkv nur noch mit dem neuen Key an.
|
||||||
|
*/
|
||||||
|
test.setTimeout(300_000);
|
||||||
|
|
||||||
|
test.beforeAll(() => zieleZuruecksetzen());
|
||||||
|
|
||||||
|
async function bestaetigen(page: Page) {
|
||||||
|
await page.goto("/bestaetigen?ziel=%2Fdeployment-key");
|
||||||
|
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||||
|
await page.locator('button[data-rolle="admin"]').click();
|
||||||
|
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||||
|
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Deployment-Key rotieren" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Deployment-Key rotieren", async ({ page }) => {
|
||||||
|
const kennung = Date.now().toString(36);
|
||||||
|
const hostA = `zz-e2e-a-${kennung}`;
|
||||||
|
const hostB = `zz-e2e-b-${kennung}`;
|
||||||
|
|
||||||
|
await anmelden(page);
|
||||||
|
await hostEinrichten(page, hostA, PORT.a);
|
||||||
|
await hostEinrichten(page, hostB, PORT.b);
|
||||||
|
const vorher = aufZiel("ziel-a", "cat /home/sshkv/.ssh/authorized_keys");
|
||||||
|
|
||||||
|
await page.goto("/einstellungen");
|
||||||
|
await page.getByRole("link", { name: "Deployment-Key rotieren" }).click();
|
||||||
|
await bestaetigen(page);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Neuen Deployment-Key erzeugen" }).click();
|
||||||
|
await expect(page.getByText("Neuer Key (noch nicht Standard)")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Schritt 2: verteilen – der neue Key kommt neben den alten.
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: /Änderung anwenden/ })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.screenshot({ path: "docs/anleitung/bilder/deployment-key-desktop.png", fullPage: true });
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||||
|
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
|
||||||
|
expect(aufZiel("ziel-a", "grep -c sshkv-deployment /home/sshkv/.ssh/authorized_keys").trim()).toBe("2");
|
||||||
|
|
||||||
|
// Schritt 3 und 4: pruefen, umstellen.
|
||||||
|
await page.getByRole("button", { name: "Neuen Key prüfen" }).click();
|
||||||
|
const ergebnis = page.getByRole("list", { name: "Ergebnis der Prüfung" });
|
||||||
|
await expect(ergebnis.getByText("angemeldet")).toHaveCount(2, { timeout: 60_000 });
|
||||||
|
await page.getByRole("button", { name: "Auf neuen Key umstellen" }).click();
|
||||||
|
|
||||||
|
// Schritt 5: alten Key entfernen und abschliessen.
|
||||||
|
await expect(page.getByRole("heading", { name: "5 · Alten Key entfernen" })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("Ausgerollt.", { exact: true })).toBeVisible({ timeout: 60_000 });
|
||||||
|
await page.getByRole("button", { name: "Rotation abschließen" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "1 · Neuen Key erzeugen" })).toBeVisible({
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nachher = aufZiel("ziel-a", "cat /home/sshkv/.ssh/authorized_keys");
|
||||||
|
expect(nachher.match(/sshkv-deployment/g)).toHaveLength(1);
|
||||||
|
expect(nachher).not.toBe(vorher);
|
||||||
|
|
||||||
|
// Der Alltag geht weiter: Verbindung pruefen klappt mit dem neuen Key.
|
||||||
|
await page.goto("/hosts");
|
||||||
|
await page.getByRole("link", { name: hostA }).first().click();
|
||||||
|
const pruefen = page.getByRole("button", { name: "Verbindung prüfen" });
|
||||||
|
await pruefen.click();
|
||||||
|
await expect(pruefen.locator("..").getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await archivieren(page, hostA);
|
||||||
|
await archivieren(page, hostB);
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einstellungen und Benachrichtigungen im gebauten Stand (#028). Braucht neben Web und Worker
|
||||||
|
* ein Mailpit (siehe tests/integration/benachrichtigung.test.ts) unter SSHKV_TEST_SMTP und
|
||||||
|
* SSHKV_TEST_MAILPIT_API.
|
||||||
|
*/
|
||||||
|
const SMTP = process.env["SSHKV_TEST_SMTP"]?.trim() || "127.0.0.1:1026";
|
||||||
|
const MAILPIT = (process.env["SSHKV_TEST_MAILPIT_API"]?.trim() || "http://127.0.0.1:8026").replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const [SMTP_HOST, SMTP_PORT] = [SMTP.slice(0, SMTP.lastIndexOf(":")), SMTP.slice(SMTP.lastIndexOf(":") + 1)];
|
||||||
|
const PASSWORT = "e2e-smtp-passwort-nie-sichtbar";
|
||||||
|
|
||||||
|
async function oidcAnmelden(page: Page, rolle: "admin" | "reader") {
|
||||||
|
await page.goto("/anmelden");
|
||||||
|
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
|
||||||
|
await page.locator(`button[data-rolle="${rolle}"]`).click();
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailKarte(page: Page) {
|
||||||
|
// Die innerste Section mit der Ueberschrift – die aeussere ist der Abschnitt „Benachrichtigungen“.
|
||||||
|
return page
|
||||||
|
.locator("section")
|
||||||
|
.filter({ has: page.getByRole("heading", { name: "E-Mail (SMTP)" }) })
|
||||||
|
.last();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mailpitSuchen(empfaenger: string): Promise<{ Subject: string }[]> {
|
||||||
|
for (let versuch = 0; versuch < 40; versuch += 1) {
|
||||||
|
const antwort = await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:${empfaenger}`)}`);
|
||||||
|
const daten = (await antwort.json()) as { messages: { Subject: string }[] };
|
||||||
|
if (daten.messages.length > 0) return daten.messages;
|
||||||
|
await new Promise((weiter) => setTimeout(weiter, 250));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Admin speichert SMTP, sendet eine Testnachricht, sie kommt in Mailpit an", async ({ page }) => {
|
||||||
|
const empfaenger = `e2e-${Date.now().toString(36)}@sshkv.test`;
|
||||||
|
await oidcAnmelden(page, "admin");
|
||||||
|
await page.goto("/einstellungen");
|
||||||
|
await expect(page.getByRole("heading", { name: "Einstellungen", level: 1 })).toBeVisible();
|
||||||
|
|
||||||
|
const karte = emailKarte(page);
|
||||||
|
await karte.getByRole("checkbox", { name: "Per E-Mail benachrichtigen" }).check();
|
||||||
|
await karte.getByLabel("Server").fill(SMTP_HOST);
|
||||||
|
await karte.getByRole("textbox", { name: "Port" }).fill(SMTP_PORT);
|
||||||
|
await karte.getByRole("radio", { name: /Unverschlüsselt/ }).check();
|
||||||
|
await expect(karte.getByRole("note")).toContainText("Klartext");
|
||||||
|
await karte.getByLabel("Absender").fill("sshkv@sshkv.test");
|
||||||
|
await karte.getByLabel("Benutzer (optional)").fill("");
|
||||||
|
await karte.getByLabel("Passwort", { exact: true }).fill(PASSWORT);
|
||||||
|
await karte.getByLabel("Empfänger").fill(empfaenger);
|
||||||
|
await page.getByRole("button", { name: "Benachrichtigungen speichern" }).click();
|
||||||
|
await expect(page.getByText("Einstellungen gespeichert.")).toBeVisible();
|
||||||
|
|
||||||
|
// Das Passwort kommt nie zurueck: Feld leer, nur „gesetzt“.
|
||||||
|
const passwort = emailKarte(page).getByLabel("Passwort", { exact: true });
|
||||||
|
await expect(passwort).toHaveValue("");
|
||||||
|
await expect(passwort).toHaveAttribute("placeholder", /gesetzt/);
|
||||||
|
await expect(emailKarte(page).getByText("gesetzt", { exact: true })).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
expect(await page.content()).not.toContain(PASSWORT);
|
||||||
|
await expect(emailKarte(page).getByLabel("Server")).toHaveValue(SMTP_HOST);
|
||||||
|
|
||||||
|
await emailKarte(page).getByRole("button", { name: "Testnachricht senden" }).click();
|
||||||
|
await expect(emailKarte(page).getByText("Erledigt.")).toBeVisible({ timeout: 30_000 });
|
||||||
|
const mails = await mailpitSuchen(empfaenger);
|
||||||
|
expect(mails.map((m) => m.Subject)).toEqual(["[sshkv] Testnachricht"]);
|
||||||
|
|
||||||
|
const zustellungen = page
|
||||||
|
.locator("section")
|
||||||
|
.filter({ has: page.getByRole("heading", { name: "Letzte Zustellungen" }) })
|
||||||
|
.last();
|
||||||
|
await expect(zustellungen.getByRole("row").nth(1)).toContainText("[sshkv] Testnachricht");
|
||||||
|
await expect(zustellungen.getByRole("row").nth(1)).toContainText("gesendet");
|
||||||
|
|
||||||
|
// Webhook nur mit https – http ohne Haekchen ergibt eine Feldmeldung.
|
||||||
|
await page.getByRole("textbox", { name: "Adresse", exact: true }).fill("http://10.0.0.9/hook");
|
||||||
|
await page.getByRole("button", { name: "Benachrichtigungen speichern" }).click();
|
||||||
|
await expect(page.locator("#feld-webhookUrl-fehler")).toContainText("Nur https://");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Leser sieht die Einstellungen ohne Ändern-Knöpfe", async ({ page }) => {
|
||||||
|
await oidcAnmelden(page, "reader");
|
||||||
|
await page.goto("/einstellungen");
|
||||||
|
await expect(page.getByRole("heading", { name: "Einstellungen", level: 1 })).toBeVisible();
|
||||||
|
await expect(page.getByText("Nur Ansicht – ändern dürfen Administratoren.")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Benachrichtigungen speichern" })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Testnachricht senden" })).toHaveCount(0);
|
||||||
|
await expect(emailKarte(page).getByLabel("Server")).toBeDisabled();
|
||||||
|
// Statt eines Passwortfelds nur der Hinweis, ob eins gesetzt ist.
|
||||||
|
await expect(page.locator('input[type="password"]')).toHaveCount(0);
|
||||||
|
await expect(emailKarte(page).getByText("gesetzt", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Instanz" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: /Deployment-Keys kopieren/ })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -48,6 +48,7 @@ test("neuen Host einrichten", async ({ page }) => {
|
|||||||
await expect(page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" })).toBeVisible({
|
await expect(page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" })).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
await page.screenshot({ path: "docs/anleitung/bilder/hostkey-bestaetigen-desktop.png", fullPage: true });
|
||||||
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click();
|
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click();
|
||||||
await expect(page.getByText("Einmalig per Passwort")).toBeVisible({ timeout: 15_000 });
|
await expect(page.getByText("Einmalig per Passwort")).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(page.getByLabel("Bootstrap-Befehl", { exact: true })).toContainText("SSHKV_BOOTSTRAP");
|
await expect(page.getByLabel("Bootstrap-Befehl", { exact: true })).toContainText("SSHKV_BOOTSTRAP");
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { anmelden } from "./hilfen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit-Log (#027) gegen den gebauten Stand: Liste, Filter, Aufklappen, Kettenpruefung und
|
||||||
|
* CSV-Export. Die Anmeldung selbst erzeugt den ersten Eintrag – ohne SSH-Testziele lauffaehig.
|
||||||
|
*/
|
||||||
|
test("Audit-Log zeigt, filtert, prüft und exportiert", async ({ page }) => {
|
||||||
|
await anmelden(page);
|
||||||
|
await page.goto("/protokoll");
|
||||||
|
await expect(page.getByRole("heading", { name: "Audit-Log" })).toBeVisible();
|
||||||
|
|
||||||
|
const tabelle = page.getByRole("table", { name: "Protokolleinträge" });
|
||||||
|
await expect(tabelle.getByRole("row").nth(1)).toBeVisible();
|
||||||
|
|
||||||
|
// Filter nach Aktionsgruppe: nur noch Anmeldungen.
|
||||||
|
await page.getByLabel("Aktion").selectOption({ label: "Anmeldung" });
|
||||||
|
await page.getByRole("button", { name: "Filtern" }).click();
|
||||||
|
await expect(page).toHaveURL(/gruppe=anmeldung/);
|
||||||
|
const zeilen = tabelle.locator("tbody > tr");
|
||||||
|
await expect(zeilen.first()).toBeVisible();
|
||||||
|
const aktionen = await zeilen.locator("td:nth-child(2)").allInnerTexts();
|
||||||
|
expect(aktionen.length).toBeGreaterThan(0);
|
||||||
|
for (const aktion of aktionen) expect(aktion).toMatch(/Anmeld|TOTP/);
|
||||||
|
|
||||||
|
// Zeile aufklappen: Hash und Vorgaenger-Hash erscheinen.
|
||||||
|
await tabelle
|
||||||
|
.getByRole("button", { name: /Einzelheiten zu Eintrag/ })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await expect(tabelle.locator('[data-feld="hash"]').first()).toHaveText(/^[0-9a-f]{64}$/);
|
||||||
|
|
||||||
|
// Kettenpruefung auf Knopfdruck.
|
||||||
|
await page.getByRole("button", { name: "Kette prüfen" }).click();
|
||||||
|
await expect(page.getByText("Hash-Kette unversehrt")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// CSV mit denselben Filtern; der Export landet selbst im Protokoll.
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
page.getByRole("link", { name: "CSV exportieren" }).click(),
|
||||||
|
]);
|
||||||
|
expect(download.suggestedFilename()).toMatch(/^sshkv-protokoll-\d{4}-\d{2}-\d{2}\.csv$/);
|
||||||
|
const text = (await readFile((await download.path())!, "utf8")).replace(/^/, "");
|
||||||
|
expect(text.split("\r\n")[0]).toBe(
|
||||||
|
'"id";"zeitpunkt";"akteur";"aktion";"ergebnis";"host";"zielbenutzer";"fingerprint";"diff";"details";"vorgaengerHash";"hash"',
|
||||||
|
);
|
||||||
|
expect(text).toContain("anmeldung.");
|
||||||
|
|
||||||
|
await page.goto("/protokoll?gruppe=protokoll");
|
||||||
|
await expect(tabelle.getByText("Protokoll exportiert (CSV)").first()).toBeVisible();
|
||||||
|
});
|
||||||
@@ -73,3 +73,22 @@ describe("Deployment-Key", () => {
|
|||||||
expect(dump).not.toContain(kern.slice(0, 40));
|
expect(dump).not.toContain(kern.slice(0, 40));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Master-Key-Pruefung beim Start (#029)", () => {
|
||||||
|
it("meldet einen falschen Master-Key verstaendlich", async () => {
|
||||||
|
const { masterKeyPruefen } = await import("@/worker/geheimnisse");
|
||||||
|
const id = await deploymentKeySicherstellen();
|
||||||
|
await expect(masterKeyPruefen(id)).resolves.toBeUndefined();
|
||||||
|
const falsch = path.join(verzeichnis, "falsch.key");
|
||||||
|
writeFileSync(falsch, randomBytes(32), { mode: 0o600 });
|
||||||
|
const richtig = process.env["SSHKV_MASTER_KEY_FILE"];
|
||||||
|
process.env["SSHKV_MASTER_KEY_FILE"] = falsch;
|
||||||
|
masterKeyVergessen();
|
||||||
|
try {
|
||||||
|
await expect(masterKeyPruefen(id)).rejects.toThrow(/passt nicht zur Datenbank/);
|
||||||
|
} finally {
|
||||||
|
process.env["SSHKV_MASTER_KEY_FILE"] = richtig;
|
||||||
|
masterKeyVergessen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createServer, type IncomingHttpHeaders, type Server } from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { signatur } from "@/lib/benachrichtigung/nachrichten";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { benachrichtigungSpeichern } from "@/server/services/benachrichtigung";
|
||||||
|
import { taktVersenden, testVersenden, zusammenfassungVersenden } from "@/worker/benachrichtigung/versand";
|
||||||
|
import { datenbankLeeren } from "../hilfen/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benachrichtigungen gegen echte Gegenstellen (#028): Mailpit fuer SMTP und ein lokaler
|
||||||
|
* HTTP-Empfaenger fuer den Webhook. Mailpit muss laufen, z. B.
|
||||||
|
* 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
|
||||||
|
* (ohne MP_SMTP_DISABLE_RDNS wartet Mailpit je Verbindung fuenf Sekunden auf Reverse-DNS).
|
||||||
|
* Adressen ueber SSHKV_TEST_SMTP (host:port) und SSHKV_TEST_MAILPIT_API.
|
||||||
|
*/
|
||||||
|
const SMTP = process.env["SSHKV_TEST_SMTP"]?.trim() || "127.0.0.1:1026";
|
||||||
|
const MAILPIT = (process.env["SSHKV_TEST_MAILPIT_API"]?.trim() || "http://127.0.0.1:8026").replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const [SMTP_HOST, SMTP_PORT] = [SMTP.slice(0, SMTP.lastIndexOf(":")), SMTP.slice(SMTP.lastIndexOf(":") + 1)];
|
||||||
|
|
||||||
|
const AKTEUR = { id: "admin", name: "Ada Admin" };
|
||||||
|
const GEHEIMNIS = "integration-webhook-geheimnis";
|
||||||
|
|
||||||
|
type Empfangen = { kopf: IncomingHttpHeaders; koerper: string };
|
||||||
|
let empfaenger: Server;
|
||||||
|
let empfangen: Empfangen[] = [];
|
||||||
|
let antwortStatus = 204;
|
||||||
|
let webhookUrl = "";
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const antwort = await fetch(`${MAILPIT}/api/v1/info`).catch(() => null);
|
||||||
|
if (!antwort?.ok) throw new Error(`Mailpit unter ${MAILPIT} nicht erreichbar – siehe Kopf dieser Datei.`);
|
||||||
|
empfaenger = createServer((anfrage, antwort) => {
|
||||||
|
const teile: Buffer[] = [];
|
||||||
|
anfrage.on("data", (teil: Buffer) => teile.push(teil));
|
||||||
|
anfrage.on("end", () => {
|
||||||
|
empfangen.push({ kopf: anfrage.headers, koerper: Buffer.concat(teile).toString("utf8") });
|
||||||
|
antwort.writeHead(antwortStatus).end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((fertig) => empfaenger.listen(0, "127.0.0.1", fertig));
|
||||||
|
webhookUrl = `http://127.0.0.1:${(empfaenger.address() as AddressInfo).port}/hook/sshkv`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await new Promise((fertig) => empfaenger.close(fertig));
|
||||||
|
});
|
||||||
|
|
||||||
|
let postfach = "";
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
empfangen = [];
|
||||||
|
antwortStatus = 204;
|
||||||
|
// Eigene Adresse je Test: Mailpit wird nicht geleert, gesucht wird nach dem Empfaenger.
|
||||||
|
postfach = `lauf-${randomUUID().slice(0, 8)}@sshkv.test`;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function einstellen(sicherheit: "KEINE" | "STARTTLS" = "KEINE") {
|
||||||
|
await benachrichtigungSpeichern(
|
||||||
|
{
|
||||||
|
emailAktiv: true,
|
||||||
|
emailServer: SMTP_HOST,
|
||||||
|
emailPort: SMTP_PORT,
|
||||||
|
emailSicherheit: sicherheit,
|
||||||
|
emailAbsender: "sshkv@sshkv.test",
|
||||||
|
emailBenutzer: "",
|
||||||
|
emailPasswortEntfernen: false,
|
||||||
|
emailEmpfaenger: [postfach],
|
||||||
|
webhookAktiv: true,
|
||||||
|
webhookUrl,
|
||||||
|
webhookHttpErlaubt: true,
|
||||||
|
webhookGeheimnis: GEHEIMNIS,
|
||||||
|
webhookGeheimnisEntfernen: false,
|
||||||
|
anlaesse: ["drift", "ablauf", "abgelaufen", "unerreichbar", "hostkey"],
|
||||||
|
},
|
||||||
|
AKTEUR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MailpitTreffer = { ID: string; Subject: string; To: { Address: string }[] };
|
||||||
|
|
||||||
|
async function postfachLesen(): Promise<MailpitTreffer[]> {
|
||||||
|
// Mailpit nimmt die Nachricht asynchron an – kurz warten, bis sie suchbar ist.
|
||||||
|
for (let versuch = 0; versuch < 20; versuch += 1) {
|
||||||
|
const antwort = await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:${postfach}`)}`);
|
||||||
|
const daten = (await antwort.json()) as { messages: MailpitTreffer[] };
|
||||||
|
if (daten.messages.length > 0) return daten.messages;
|
||||||
|
await new Promise((weiter) => setTimeout(weiter, 150));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mailLesen(id: string): Promise<{ Text: string; HTML: string; Subject: string }> {
|
||||||
|
const antwort = await fetch(`${MAILPIT}/api/v1/message/${id}`);
|
||||||
|
return (await antwort.json()) as { Text: string; HTML: string; Subject: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("E-Mail über Mailpit", () => {
|
||||||
|
it("Testnachricht kommt an", async () => {
|
||||||
|
await einstellen();
|
||||||
|
expect(await testVersenden("EMAIL", AKTEUR)).toBeNull();
|
||||||
|
const treffer = await postfachLesen();
|
||||||
|
expect(treffer).toHaveLength(1);
|
||||||
|
expect(treffer[0]?.Subject).toBe("[sshkv] Testnachricht");
|
||||||
|
const mail = await mailLesen(treffer[0]!.ID);
|
||||||
|
expect(mail.Text).toContain("Versand per E-Mail funktioniert");
|
||||||
|
expect(mail.HTML).toContain("Testnachricht");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Zusammenfassung mit Drift je Host, Text und HTML", async () => {
|
||||||
|
await einstellen();
|
||||||
|
const host = await prisma.host.create({
|
||||||
|
data: { name: "srv-mail", adresse: "10.1.1.1", bootstrapAm: new Date(), erreichbar: true },
|
||||||
|
});
|
||||||
|
await prisma.abweichung.create({
|
||||||
|
data: {
|
||||||
|
hostId: host.id,
|
||||||
|
art: "UNBEKANNTER_SCHLUESSEL",
|
||||||
|
zielbenutzer: "root",
|
||||||
|
fingerprint: "SHA256:fremd",
|
||||||
|
details: { text: "Kommentar <b>fett</b>" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await zusammenfassungVersenden()).toEqual({ gesendet: 2, fehlgeschlagen: 0 });
|
||||||
|
const treffer = await postfachLesen();
|
||||||
|
expect(treffer[0]?.Subject).toBe("[sshkv] Zusammenfassung: 1 neue Abweichung");
|
||||||
|
const mail = await mailLesen(treffer[0]!.ID);
|
||||||
|
expect(mail.Text).toContain("srv-mail (10.1.1.1):");
|
||||||
|
expect(mail.Text).toContain("Unbekannter Schlüssel – Benutzer root – SHA256:fremd");
|
||||||
|
expect(mail.HTML).toContain("Kommentar <b>fett</b>");
|
||||||
|
|
||||||
|
// Zweiter Lauf: nichts Neues, keine zweite Mail.
|
||||||
|
expect(await zusammenfassungVersenden()).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||||||
|
expect(await postfachLesen()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("STARTTLS ist Pflicht, wenn eingestellt – ohne TLS kein Versand", async () => {
|
||||||
|
await einstellen("STARTTLS");
|
||||||
|
const fehler = await testVersenden("EMAIL", AKTEUR);
|
||||||
|
expect(fehler).toMatch(/^SMTP: /);
|
||||||
|
const zeile = await prisma.zustellung.findFirstOrThrow({ where: { kanal: "EMAIL" } });
|
||||||
|
expect(zeile.ergebnis).toBe("FEHLGESCHLAGEN");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Webhook an lokalen Empfänger", () => {
|
||||||
|
it("signiert den Body mit HMAC-SHA256", async () => {
|
||||||
|
await einstellen();
|
||||||
|
expect(await testVersenden("WEBHOOK", AKTEUR)).toBeNull();
|
||||||
|
expect(empfangen).toHaveLength(1);
|
||||||
|
const { kopf, koerper } = empfangen[0]!;
|
||||||
|
expect(kopf["content-type"]).toMatch(/^application\/json/);
|
||||||
|
expect(kopf["x-sshkv-ereignis"]).toBe("test");
|
||||||
|
expect(kopf["x-sshkv-signatur"]).toBe(signatur(koerper, GEHEIMNIS));
|
||||||
|
expect(JSON.parse(koerper)).toMatchObject({ ereignis: "test", betreff: "[sshkv] Testnachricht" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geänderter Host-Key geht sofort raus; HTTP 500 wird später wiederholt", async () => {
|
||||||
|
await einstellen();
|
||||||
|
const host = await prisma.host.create({
|
||||||
|
data: { name: "srv-hook", adresse: "10.2.2.2", bootstrapAm: new Date() },
|
||||||
|
});
|
||||||
|
await prisma.abweichung.create({
|
||||||
|
data: {
|
||||||
|
hostId: host.id,
|
||||||
|
art: "HOSTKEY_GEAENDERT",
|
||||||
|
fingerprint: "SHA256:neu",
|
||||||
|
details: { erwartet: "SHA256:alt", algorithmus: "ssh-ed25519" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
antwortStatus = 500;
|
||||||
|
const jetzt = new Date();
|
||||||
|
expect(await taktVersenden(jetzt)).toEqual({ gesendet: 1, fehlgeschlagen: 1 });
|
||||||
|
const zeile = await prisma.zustellung.findFirstOrThrow({ where: { kanal: "WEBHOOK" } });
|
||||||
|
expect(zeile).toMatchObject({ ergebnis: "FEHLGESCHLAGEN", fehler: "Webhook antwortet mit HTTP 500." });
|
||||||
|
|
||||||
|
antwortStatus = 204;
|
||||||
|
empfangen = [];
|
||||||
|
expect(await taktVersenden(new Date(jetzt.getTime() + 6 * 60_000))).toEqual({
|
||||||
|
gesendet: 1,
|
||||||
|
fehlgeschlagen: 0,
|
||||||
|
});
|
||||||
|
const json = JSON.parse(empfangen[0]!.koerper) as Record<string, unknown>;
|
||||||
|
expect(json).toMatchObject({
|
||||||
|
ereignis: "hostkey.geaendert",
|
||||||
|
dringend: true,
|
||||||
|
host: { name: "srv-hook", adresse: "10.2.2.2" },
|
||||||
|
befunde: [{ fingerprint: "SHA256:neu", erwartet: "SHA256:alt" }],
|
||||||
|
});
|
||||||
|
expect(typeof json["zeitstempel"]).toBe("string");
|
||||||
|
expect(empfangen[0]!.kopf["x-sshkv-signatur"]).toBe(signatur(empfangen[0]!.koerper, GEHEIMNIS));
|
||||||
|
// Die E-Mail ging beim ersten Mal durch und kommt nicht noch einmal.
|
||||||
|
expect(await postfachLesen()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { authorizedKeysLesen } from "@/lib/ssh/authorized-keys";
|
||||||
|
import {
|
||||||
|
DeploymentKeyFehler,
|
||||||
|
instanzZiele,
|
||||||
|
rotationsStand,
|
||||||
|
umstellen,
|
||||||
|
} from "@/server/services/deploymentkey";
|
||||||
|
import { anmeldebenutzerVon } from "@/server/services/soll";
|
||||||
|
import { deploymentKeySicherstellen, privateKeyLaden } from "@/worker/geheimnisse";
|
||||||
|
import { hostVerbinden } from "@/worker/ssh/host";
|
||||||
|
import { authorizedKeysHolen } from "@/worker/ssh/ssh-dateien";
|
||||||
|
import { datenbankLeeren } from "../hilfen/db";
|
||||||
|
import { warteschlangeStoppen } from "../hilfen/vorgang";
|
||||||
|
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
|
||||||
|
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotation des Deployment-Keys gegen echte Hosts (#031): danach meldet sich sshkv nur noch mit
|
||||||
|
* dem neuen Key an, der alte steht auf keinem Host mehr, und zu keinem Zeitpunkt war ein Host
|
||||||
|
* ohne funktionierenden Key.
|
||||||
|
*/
|
||||||
|
const AKTEUR = { id: "", name: "Test-Admin" };
|
||||||
|
let aufraeumen: () => void;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
|
||||||
|
aufraeumen = masterKeyAnlegen();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
aufraeumen();
|
||||||
|
await warteschlangeStoppen();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
await deploymentKeySicherstellen();
|
||||||
|
const reset = "userdel -r sshkv 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
|
||||||
|
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
|
||||||
|
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const schritt = (s: "erzeugen" | "pruefen" | "abschliessen") =>
|
||||||
|
vorgangLaufen("ROTATION", { eingabe: { schritt: s }, angefordertVonId: AKTEUR.id });
|
||||||
|
|
||||||
|
async function trockenlaufUndAusrollen() {
|
||||||
|
const trockenlauf = await vorgangLaufen("TROCKENLAUF", {
|
||||||
|
eingabe: { ziele: await instanzZiele() },
|
||||||
|
angefordertVonId: AKTEUR.id,
|
||||||
|
});
|
||||||
|
expect(trockenlauf.status).toBe("ERLEDIGT");
|
||||||
|
const ausrollen = await vorgangLaufen("AUSROLLEN", {
|
||||||
|
eingabe: { trockenlaufId: trockenlauf.id },
|
||||||
|
angefordertVonId: AKTEUR.id,
|
||||||
|
});
|
||||||
|
expect(ausrollen.fehler).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
type Host = Awaited<ReturnType<typeof eingerichteterHost>>;
|
||||||
|
|
||||||
|
async function fingerprintsAuf(host: Host): Promise<string[]> {
|
||||||
|
const verbindung = await hostVerbinden(host);
|
||||||
|
try {
|
||||||
|
const datei = await authorizedKeysHolen(verbindung, host, anmeldebenutzerVon(host));
|
||||||
|
return authorizedKeysLesen(datei.inhalt).zeilen.flatMap((z) =>
|
||||||
|
z.art === "schluessel" ? [z.fingerprint] : [],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
verbindung.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function anmeldungKlappt(host: Host, schluesselId: string): Promise<boolean> {
|
||||||
|
const privateKey = await privateKeyLaden(schluesselId);
|
||||||
|
try {
|
||||||
|
const verbindung = await hostVerbinden(host, { benutzer: anmeldebenutzerVon(host), privateKey });
|
||||||
|
verbindung.end();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
privateKey.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Rotation des Deployment-Keys", () => {
|
||||||
|
it("tauscht den Key auf allen Hosts ohne Unterbrechung aus", async () => {
|
||||||
|
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||||
|
const b = await eingerichteterHost(zielB, "ziel-b", "ROOT");
|
||||||
|
const alt = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
|
||||||
|
|
||||||
|
expect((await schritt("erzeugen")).status).toBe("ERLEDIGT");
|
||||||
|
const neu = (await rotationsStand()).nachfolger!;
|
||||||
|
expect(neu.vorgaengerId).toBe(alt.id);
|
||||||
|
// Ein zweites Erzeugen waehrend der Rotation wird abgelehnt.
|
||||||
|
expect((await schritt("erzeugen")).status).toBe("FEHLGESCHLAGEN");
|
||||||
|
|
||||||
|
// Vor dem Verteilen scheitert die Pruefung – und ohne erfolgreiche Pruefung kein Umstellen.
|
||||||
|
await schritt("pruefen");
|
||||||
|
expect((await rotationsStand()).pruefung?.every((p) => !p.ok)).toBe(true);
|
||||||
|
await expect(umstellen(AKTEUR)).rejects.toThrow(DeploymentKeyFehler);
|
||||||
|
|
||||||
|
await trockenlaufUndAusrollen();
|
||||||
|
for (const host of [a, b]) {
|
||||||
|
expect(await fingerprintsAuf(host)).toEqual(expect.arrayContaining([alt.fingerprint, neu.fingerprint]));
|
||||||
|
}
|
||||||
|
await schritt("pruefen");
|
||||||
|
expect((await rotationsStand()).pruefung?.map((p) => p.ok)).toEqual([true, true]);
|
||||||
|
|
||||||
|
await umstellen(AKTEUR);
|
||||||
|
const umgestellt = await rotationsStand();
|
||||||
|
expect(umgestellt.aktuell?.id).toBe(neu.id);
|
||||||
|
expect(umgestellt.phase).toBe("aufraeumen");
|
||||||
|
// Abschliessen geht erst, wenn der alte Key ueberall entfernt ist.
|
||||||
|
expect((await schritt("abschliessen")).fehler).toMatch(/steht noch auf/);
|
||||||
|
|
||||||
|
await trockenlaufUndAusrollen();
|
||||||
|
for (const host of [a, b]) {
|
||||||
|
const fingerprints = await fingerprintsAuf(host);
|
||||||
|
expect(fingerprints).toContain(neu.fingerprint);
|
||||||
|
expect(fingerprints).not.toContain(alt.fingerprint);
|
||||||
|
expect(await anmeldungKlappt(host, alt.id)).toBe(false);
|
||||||
|
expect(await anmeldungKlappt(host, neu.id)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((await schritt("abschliessen")).status).toBe("ERLEDIGT");
|
||||||
|
const verworfen = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
|
||||||
|
expect(verworfen.status).toBe("ROTIERT");
|
||||||
|
expect(verworfen.privatGeloeschtAm).not.toBeNull();
|
||||||
|
expect((await rotationsStand()).phase).toBe("bereit");
|
||||||
|
|
||||||
|
// Der Alltag geht mit dem neuen Key weiter.
|
||||||
|
const abgleich = await vorgangLaufen("ABGLEICH", { hostId: a.id });
|
||||||
|
expect(abgleich.status).toBe("ERLEDIGT");
|
||||||
|
expect(await prisma.abweichung.count({ where: { hostId: a.id, behobenAm: null } })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst Hosts mit eigenem Deployment-Key aus", async () => {
|
||||||
|
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||||
|
const b = await eingerichteterHost(zielB, "ziel-b");
|
||||||
|
const eigener = await prisma.schluessel.findFirstOrThrow({ where: { instanzStandard: true } });
|
||||||
|
// b bekommt (formal) einen eigenen Key – derselbe genuegt, es zaehlt die Zuordnung.
|
||||||
|
await prisma.host.update({ where: { id: b.id }, data: { deploymentSchluesselId: eigener.id } });
|
||||||
|
expect((await instanzZiele()).map((z) => z.hostId)).toEqual([a.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,7 +29,8 @@ export async function vorgangLaufen(
|
|||||||
| "VERBINDUNGSTEST"
|
| "VERBINDUNGSTEST"
|
||||||
| "ZIELKONTO_ANLEGEN"
|
| "ZIELKONTO_ANLEGEN"
|
||||||
| "ZIELKONTO_ENTFERNEN"
|
| "ZIELKONTO_ENTFERNEN"
|
||||||
| "ABGLEICH",
|
| "ABGLEICH"
|
||||||
|
| "ROTATION",
|
||||||
daten: {
|
daten: {
|
||||||
hostId?: string;
|
hostId?: string;
|
||||||
eingabe?: object;
|
eingabe?: object;
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AKTIONEN, AKTIONSGRUPPEN, aktionenDerGruppe, aktionText, aktionTon } from "@/lib/protokoll-aktionen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jede Protokoll-Aktion im Quellcode hat einen lesbaren Text (#027). Gesucht wird in allen
|
||||||
|
* Dateien, die `protokollieren(` rufen, nach `aktion: …`/`aktion = …` (auch in Ternaries), und
|
||||||
|
* ueberall nach Literalen, die mit einem bekannten Praefix beginnen (`"schluessel.uebernommen"`
|
||||||
|
* wird z. B. als Argument durchgereicht).
|
||||||
|
*/
|
||||||
|
function dateien(verzeichnis: string): string[] {
|
||||||
|
return readdirSync(verzeichnis).flatMap((name) => {
|
||||||
|
const pfad = path.join(verzeichnis, name);
|
||||||
|
if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
|
||||||
|
return /\.tsx?$/.test(name) ? [pfad] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUELLEN = dateien("src").map((datei) => ({ datei, inhalt: readFileSync(datei, "utf8") }));
|
||||||
|
const LITERAL = /"([^"\n]+)"/g;
|
||||||
|
|
||||||
|
function aktionenImCode(): Map<string, string> {
|
||||||
|
const gefunden = new Map<string, string>();
|
||||||
|
const praefixe = new Set(Object.keys(AKTIONEN).map((a) => a.split(".")[0]!));
|
||||||
|
for (const { datei, inhalt } of QUELLEN) {
|
||||||
|
if (inhalt.includes("protokollieren(")) {
|
||||||
|
for (const treffer of inhalt.matchAll(/\baktion\s*[:=]\s*([^,\n]+)/g)) {
|
||||||
|
// Bei `a === "x" ? "eins" : "zwei"` zaehlen nur die Zweige, nicht die Bedingung.
|
||||||
|
const ausdruck = treffer[1]!.includes("?")
|
||||||
|
? treffer[1]!.slice(treffer[1]!.indexOf("?"))
|
||||||
|
: treffer[1]!;
|
||||||
|
for (const [, literal] of ausdruck.matchAll(LITERAL)) gefunden.set(literal!, datei);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [, literal] of inhalt.matchAll(/"([a-z_]+(?:\.[a-z_]+)+)"/g)) {
|
||||||
|
if (praefixe.has(literal!.split(".")[0]!) && !literal!.endsWith(".ts")) gefunden.set(literal!, datei);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gefunden;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Protokoll-Aktionen", () => {
|
||||||
|
const imCode = aktionenImCode();
|
||||||
|
|
||||||
|
it("findet die Aktionen im Quellcode", () => {
|
||||||
|
expect(imCode.size).toBeGreaterThan(40);
|
||||||
|
expect(imCode.has("authorized_keys.geschrieben")).toBe(true);
|
||||||
|
expect(imCode.has("hostkey.ersetzt")).toBe(true);
|
||||||
|
expect(imCode.has("privatekey.angezeigt")).toBe(true);
|
||||||
|
expect(imCode.has("schluessel.uebernommen")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([...imCode])("%s hat einen lesbaren Text (%s)", (aktion) => {
|
||||||
|
expect(AKTIONEN[aktion], `${aktion} fehlt in src/lib/protokoll-aktionen.ts`).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("jede Gruppe hat Aktionen", () => {
|
||||||
|
for (const gruppe of Object.keys(AKTIONSGRUPPEN) as (keyof typeof AKTIONSGRUPPEN)[]) {
|
||||||
|
expect(aktionenDerGruppe(gruppe).length, gruppe).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faellt fuer unbekannte Aktionen auf den technischen Namen zurueck", () => {
|
||||||
|
expect(aktionText("hostkey.ersetzt")).toBe("Host-Key ersetzt");
|
||||||
|
expect(aktionText("gibt.es.nicht")).toBe("gibt.es.nicht");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faerbt nach Ergebnis und Art", () => {
|
||||||
|
expect(aktionTon("authorized_keys.geschrieben", "ERFOLG")).toBe("ok");
|
||||||
|
expect(aktionTon("anmeldung.oidc", "ERFOLG")).toBe("neutral");
|
||||||
|
expect(aktionTon("anmeldung.verweigert", "VERWEIGERT")).toBe("gefahr");
|
||||||
|
expect(aktionTon("verbindungstest", "FEHLER")).toBe("gefahr");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import { createSocket } from "node:dgram";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { LEERER_FILTER, protokollFilterAus, type ProtokollFilter } from "@/lib/protokoll-filter";
|
||||||
|
import {
|
||||||
|
protokollAkteure,
|
||||||
|
protokollSeite,
|
||||||
|
protokollStapelweise,
|
||||||
|
protokollZaehlen,
|
||||||
|
SEITENGROESSE,
|
||||||
|
} from "@/server/queries/protokoll";
|
||||||
|
import {
|
||||||
|
kettePruefenZwischengespeichert,
|
||||||
|
ketteZwischengespeichert,
|
||||||
|
ketteZwischenspeicherLeeren,
|
||||||
|
protokollieren,
|
||||||
|
syslogKonfig,
|
||||||
|
syslogZuruecksetzen,
|
||||||
|
} from "@/server/services/protokoll";
|
||||||
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protokollansicht (#027) gegen die Testdatenbank: Filter, Cursor, CSV-Route, Kettenpruefung
|
||||||
|
* und Syslog-Weiterleitung. Die Sitzung der Route wird nachgebildet – geprueft wird, dass der
|
||||||
|
* Handler das Recht selbst verlangt.
|
||||||
|
*/
|
||||||
|
const recht = vi.hoisted(() => ({ erlaubt: true }));
|
||||||
|
vi.mock("@/server/auth/sitzungspflicht", async (original) => {
|
||||||
|
const echt = await original<typeof import("@/server/auth/sitzungspflicht")>();
|
||||||
|
return {
|
||||||
|
...echt,
|
||||||
|
rechtErforderlich: vi.fn(async () => {
|
||||||
|
if (!recht.erlaubt) throw new echt.RechteFehler("Nicht angemeldet.");
|
||||||
|
return { benutzer: { id: "b-leser", name: "Lea Leser", rolle: "LESER" } };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
type Roh = {
|
||||||
|
aktion: string;
|
||||||
|
akteurName?: string;
|
||||||
|
ergebnis?: "ERFOLG" | "FEHLER" | "VERWEIGERT";
|
||||||
|
hostName?: string | null;
|
||||||
|
zielbenutzer?: string | null;
|
||||||
|
fingerprint?: string | null;
|
||||||
|
zeitpunkt?: Date;
|
||||||
|
diff?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function eintragen(daten: Roh) {
|
||||||
|
return prisma.protokoll.create({
|
||||||
|
data: {
|
||||||
|
akteurName: daten.akteurName ?? "Florian",
|
||||||
|
aktion: daten.aktion,
|
||||||
|
ergebnis: daten.ergebnis ?? "ERFOLG",
|
||||||
|
hostName: daten.hostName ?? null,
|
||||||
|
zielbenutzer: daten.zielbenutzer ?? null,
|
||||||
|
fingerprint: daten.fingerprint ?? null,
|
||||||
|
diff: daten.diff ?? null,
|
||||||
|
...(daten.zeitpunkt ? { zeitpunkt: daten.zeitpunkt } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = (teil: Partial<ProtokollFilter>): ProtokollFilter => ({ ...LEERER_FILTER, ...teil });
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
recht.erlaubt = true;
|
||||||
|
ketteZwischenspeicherLeeren();
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("protokollSeite", () => {
|
||||||
|
it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => {
|
||||||
|
// IDs sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – nur die Ordnung zaehlt.
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < SEITENGROESSE + 7; i += 1)
|
||||||
|
ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id));
|
||||||
|
const erste = await protokollSeite(LEERER_FILTER);
|
||||||
|
expect(erste.zeilen.map((z) => z.id)).toEqual(ids.slice(0, SEITENGROESSE));
|
||||||
|
expect(erste.weiterVor).toBe(ids[SEITENGROESSE - 1]);
|
||||||
|
|
||||||
|
// Ein neuer Eintrag zwischen zwei Seiten verschiebt den Cursor nicht (anders als OFFSET).
|
||||||
|
await eintragen({ aktion: "host.geaendert" });
|
||||||
|
const zweite = await protokollSeite(LEERER_FILTER, BigInt(erste.weiterVor!));
|
||||||
|
expect(zweite.zeilen.map((z) => z.id)).toEqual(ids.slice(SEITENGROESSE));
|
||||||
|
expect(zweite.weiterVor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert Hash, Vorgaenger-Hash und Details als Liste", async () => {
|
||||||
|
await protokollieren({
|
||||||
|
akteur: "System",
|
||||||
|
aktion: "abgleich",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
details: { befunde: 2, benutzer: ["root"], hinweis: "ok" },
|
||||||
|
});
|
||||||
|
const [zeile] = (await protokollSeite(LEERER_FILTER)).zeilen;
|
||||||
|
expect(zeile!.hash).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(zeile!.vorgaengerHash).toBe("");
|
||||||
|
// jsonb ordnet die Schluessel selbst; die Reihenfolge ist daher nicht Teil der Zusage.
|
||||||
|
expect(zeile!.details).toHaveLength(3);
|
||||||
|
expect(zeile!.details).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ name: "befunde", wert: "2" },
|
||||||
|
{ name: "benutzer", wert: '[\n "root"\n]' },
|
||||||
|
{ name: "hinweis", wert: "ok" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtert nach Aktionsgruppe, Ergebnis, Akteur, Host und Freitext", async () => {
|
||||||
|
await eintragen({ aktion: "anmeldung.oidc", akteurName: "Anna" });
|
||||||
|
await eintragen({ aktion: "anmeldung.verweigert", akteurName: "Mallory", ergebnis: "VERWEIGERT" });
|
||||||
|
await eintragen({
|
||||||
|
aktion: "authorized_keys.geschrieben",
|
||||||
|
hostName: "srv-pve01",
|
||||||
|
zielbenutzer: "backup",
|
||||||
|
fingerprint: "SHA256:abcdef",
|
||||||
|
});
|
||||||
|
await eintragen({ aktion: "hostkey.ersetzt", hostName: "srv-db02" });
|
||||||
|
|
||||||
|
const aktionen = async (f: Partial<ProtokollFilter>) =>
|
||||||
|
(await protokollSeite(filter(f))).zeilen.map((z) => z.aktion);
|
||||||
|
|
||||||
|
expect(await aktionen({ gruppe: "anmeldung" })).toEqual(["anmeldung.verweigert", "anmeldung.oidc"]);
|
||||||
|
expect(await aktionen({ gruppe: "hostkey" })).toEqual(["hostkey.ersetzt"]);
|
||||||
|
expect(await aktionen({ ergebnis: "VERWEIGERT" })).toEqual(["anmeldung.verweigert"]);
|
||||||
|
expect(await aktionen({ akteur: "Anna" })).toEqual(["anmeldung.oidc"]);
|
||||||
|
expect(await aktionen({ host: "PVE" })).toEqual(["authorized_keys.geschrieben"]);
|
||||||
|
expect(await aktionen({ q: "abcdef" })).toEqual(["authorized_keys.geschrieben"]);
|
||||||
|
expect(await aktionen({ q: "BACKUP" })).toEqual(["authorized_keys.geschrieben"]);
|
||||||
|
expect(await aktionen({ q: "db02" })).toEqual(["hostkey.ersetzt"]);
|
||||||
|
expect(await aktionen({ gruppe: "ausrollen", host: "db02" })).toEqual([]);
|
||||||
|
|
||||||
|
expect(await protokollZaehlen(filter({ gruppe: "anmeldung" }))).toEqual({ gesamt: 4, treffer: 2 });
|
||||||
|
expect(await protokollAkteure()).toEqual(["Anna", "Florian", "Mallory"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtert den Zeitraum nach Berliner Kalendertagen", async () => {
|
||||||
|
// 11.10. 23:30 Berlin = 21:30 UTC; 12.10. 00:30 Berlin = 22:30 UTC am 11.10.
|
||||||
|
await eintragen({ aktion: "host.angelegt", zeitpunkt: new Date("2026-10-11T21:30:00Z") });
|
||||||
|
await eintragen({ aktion: "host.geaendert", zeitpunkt: new Date("2026-10-11T22:30:00Z") });
|
||||||
|
await eintragen({ aktion: "host.archiviert", zeitpunkt: new Date("2026-10-12T21:59:00Z") });
|
||||||
|
await eintragen({ aktion: "kunde.angelegt", zeitpunkt: new Date("2026-10-12T22:01:00Z") });
|
||||||
|
|
||||||
|
const aktionen = async (f: Partial<ProtokollFilter>) =>
|
||||||
|
(await protokollSeite(filter(f))).zeilen.map((z) => z.aktion);
|
||||||
|
expect(await aktionen({ von: "2026-10-12", bis: "2026-10-12" })).toEqual([
|
||||||
|
"host.archiviert",
|
||||||
|
"host.geaendert",
|
||||||
|
]);
|
||||||
|
expect(await aktionen({ bis: "2026-10-11" })).toEqual(["host.angelegt"]);
|
||||||
|
expect(await aktionen({ von: "2026-10-13" })).toEqual(["kunde.angelegt"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignoriert unbekannte Filterwerte", () => {
|
||||||
|
expect(
|
||||||
|
protokollFilterAus({
|
||||||
|
gruppe: "drop table",
|
||||||
|
ergebnis: "VIELLEICHT",
|
||||||
|
von: "12.10.2026",
|
||||||
|
bis: "2026-13-45",
|
||||||
|
}),
|
||||||
|
).toEqual(LEERER_FILTER);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("protokollStapelweise", () => {
|
||||||
|
it("liest alle Treffer in Stapeln", async () => {
|
||||||
|
const erwartet: bigint[] = [];
|
||||||
|
for (let i = 0; i < 12; i += 1) {
|
||||||
|
const zeile = await eintragen({ aktion: i % 2 ? "host.angelegt" : "anmeldung.oidc" });
|
||||||
|
if (i % 2) erwartet.unshift(zeile.id);
|
||||||
|
}
|
||||||
|
const groessen: number[] = [];
|
||||||
|
const ids: bigint[] = [];
|
||||||
|
for await (const stapel of protokollStapelweise(filter({ gruppe: "hosts" }), 4)) {
|
||||||
|
groessen.push(stapel.length);
|
||||||
|
ids.push(...stapel.map((z) => z.id));
|
||||||
|
}
|
||||||
|
expect(groessen).toEqual([4, 2]);
|
||||||
|
expect(ids).toEqual(erwartet);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/protokoll.csv", () => {
|
||||||
|
async function abrufen(query = "") {
|
||||||
|
const { GET } = await import("@/app/api/protokoll.csv/route");
|
||||||
|
return GET(new Request(`http://localhost/api/protokoll.csv${query}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("verweigert ohne Recht und protokolliert dann nichts", async () => {
|
||||||
|
recht.erlaubt = false;
|
||||||
|
const antwort = await abrufen();
|
||||||
|
expect(antwort.status).toBe(403);
|
||||||
|
expect(await prisma.protokoll.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert Kopfzeile, Zeilen und entschaerft Formeln", async () => {
|
||||||
|
await eintragen({
|
||||||
|
aktion: "authorized_keys.geschrieben",
|
||||||
|
akteurName: "=HYPERLINK(1)",
|
||||||
|
hostName: "srv-pve01",
|
||||||
|
zielbenutzer: "root",
|
||||||
|
fingerprint: "SHA256:abc",
|
||||||
|
diff: '+ ssh-ed25519 AAAA "x"\n- ssh-rsa BBBB',
|
||||||
|
});
|
||||||
|
const antwort = await abrufen();
|
||||||
|
expect(antwort.status).toBe(200);
|
||||||
|
expect(antwort.headers.get("content-type")).toContain("text/csv");
|
||||||
|
expect(antwort.headers.get("content-disposition")).toMatch(/sshkv-protokoll-\d{4}-\d{2}-\d{2}\.csv/);
|
||||||
|
const text = (await antwort.text()).replace(/^/, "");
|
||||||
|
const [kopf] = text.split("\r\n");
|
||||||
|
expect(kopf).toBe(
|
||||||
|
'"id";"zeitpunkt";"akteur";"aktion";"ergebnis";"host";"zielbenutzer";"fingerprint";"diff";"details";"vorgaengerHash";"hash"',
|
||||||
|
);
|
||||||
|
expect(text).toContain(`"'=HYPERLINK(1)";"authorized_keys.geschrieben";"ERFOLG";"srv-pve01";"root"`);
|
||||||
|
// Mehrzeiliger Diff bleibt eine Zelle; das fuehrende „+“ wird wie eine Formel entschaerft.
|
||||||
|
expect(text).toContain(`"'+ ssh-ed25519 AAAA ""x""\n- ssh-rsa BBBB"`);
|
||||||
|
expect(text).toMatch(/\r\n"\d+";"\d{4}-\d{2}-\d{2}T[\d:.]+Z";"'=HYPERLINK/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("protokolliert den Export mit den Filtern", async () => {
|
||||||
|
await (await abrufen("?gruppe=hostkey&q=srv")).text();
|
||||||
|
const [eintrag] = await prisma.protokoll.findMany({ where: { aktion: "protokoll.exportiert" } });
|
||||||
|
expect(eintrag).toMatchObject({ akteurName: "Lea Leser", akteurId: "b-leser", ergebnis: "ERFOLG" });
|
||||||
|
expect(eintrag!.details).toEqual({ filter: { gruppe: "hostkey", q: "srv" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wendet die Filter der Seite an", async () => {
|
||||||
|
await eintragen({ aktion: "hostkey.ersetzt", hostName: "srv-a" });
|
||||||
|
await eintragen({ aktion: "anmeldung.oidc" });
|
||||||
|
const text = await (await abrufen("?gruppe=hostkey")).text();
|
||||||
|
expect(text).toContain("hostkey.ersetzt");
|
||||||
|
expect(text).not.toContain("anmeldung.oidc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streamt grosse Protokolle ueber mehrere Stapel", async () => {
|
||||||
|
await prisma.protokoll.createMany({
|
||||||
|
data: Array.from({ length: 1203 }, (_, i) => ({
|
||||||
|
akteurName: "System",
|
||||||
|
aktion: "abgleich",
|
||||||
|
ergebnis: "ERFOLG" as const,
|
||||||
|
hostName: `h${i}`,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const text = await (await abrufen("?gruppe=abgleich")).text();
|
||||||
|
expect(text.trimEnd().split("\r\n")).toHaveLength(1 + 1203);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Kettenpruefung", () => {
|
||||||
|
it("prueft auf Anfrage und haelt das Ergebnis 5 Minuten", async () => {
|
||||||
|
await eintragen({ aktion: "host.angelegt" });
|
||||||
|
await eintragen({ aktion: "host.geaendert" });
|
||||||
|
expect(ketteZwischengespeichert()).toBeNull();
|
||||||
|
const ergebnis = await kettePruefenZwischengespeichert("System");
|
||||||
|
expect(ergebnis).toMatchObject({ fehlerhafteId: null, eintraege: 2 });
|
||||||
|
expect(ketteZwischengespeichert()).toBe(ergebnis);
|
||||||
|
expect(ketteZwischengespeichert(new Date(Date.now() + 6 * 60 * 1000))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet die erste fehlerhafte ID und protokolliert den Fund", async () => {
|
||||||
|
await eintragen({ aktion: "host.angelegt" });
|
||||||
|
const zweiter = await eintragen({ aktion: "host.geaendert" });
|
||||||
|
await eintragen({ aktion: "host.archiviert" });
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" DISABLE TRIGGER "protokoll_kein_update"`),
|
||||||
|
prisma.$executeRawUnsafe(`UPDATE "Protokoll" SET "aktion" = 'gefaelscht' WHERE "id" = ${zweiter.id}`),
|
||||||
|
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" ENABLE TRIGGER "protokoll_kein_update"`),
|
||||||
|
]);
|
||||||
|
const ergebnis = await kettePruefenZwischengespeichert({ id: "b1", name: "Florian" });
|
||||||
|
expect(ergebnis.fehlerhafteId).toBe(String(zweiter.id));
|
||||||
|
expect(await prisma.protokoll.count({ where: { aktion: "protokoll.kette_beschaedigt" } })).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Syslog-Weiterleitung in protokollieren()", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env["SSHKV_SYSLOG_HOST"];
|
||||||
|
delete process.env["SSHKV_SYSLOG_PORT"];
|
||||||
|
syslogZuruecksetzen();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist ohne Umgebung aus", () => {
|
||||||
|
syslogZuruecksetzen();
|
||||||
|
expect(syslogKonfig()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendet nach dem Schreiben – ohne Details und Diff", async () => {
|
||||||
|
const empfaenger = createSocket("udp4");
|
||||||
|
const eingang = new Promise<string>((fertig) =>
|
||||||
|
empfaenger.once("message", (d) => fertig(d.toString("utf8"))),
|
||||||
|
);
|
||||||
|
await new Promise<void>((fertig) => empfaenger.bind(0, "127.0.0.1", fertig));
|
||||||
|
process.env["SSHKV_SYSLOG_HOST"] = "127.0.0.1";
|
||||||
|
process.env["SSHKV_SYSLOG_PORT"] = String((empfaenger.address() as AddressInfo).port);
|
||||||
|
syslogZuruecksetzen();
|
||||||
|
try {
|
||||||
|
await protokollieren({
|
||||||
|
akteur: { id: "b1", name: "Florian" },
|
||||||
|
aktion: "privatekey.angezeigt",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: "SHA256:xyz",
|
||||||
|
diff: "+ geheimer-diff",
|
||||||
|
details: { pfad: "/geheim/pfad", passwort: "x" },
|
||||||
|
});
|
||||||
|
const zeile = await eingang;
|
||||||
|
const [eintrag] = await prisma.protokoll.findMany();
|
||||||
|
expect(zeile).toMatch(/^<85>1 /);
|
||||||
|
expect(zeile).toContain(`hash="${eintrag!.hash}"`);
|
||||||
|
expect(zeile).toContain(`id="${eintrag!.id}"`);
|
||||||
|
expect(zeile).toContain('fingerprint="SHA256:xyz"');
|
||||||
|
expect(zeile).not.toContain("geheimer-diff");
|
||||||
|
expect(zeile).not.toContain("/geheim/pfad");
|
||||||
|
} finally {
|
||||||
|
empfaenger.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schreibt auch, wenn der Syslog-Server nicht erreichbar ist", async () => {
|
||||||
|
process.env["SSHKV_SYSLOG_HOST"] = "nicht-aufloesbar.invalid";
|
||||||
|
syslogZuruecksetzen();
|
||||||
|
const fehler = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
await protokollieren({ akteur: "System", aktion: "abgleich", ergebnis: "ERFOLG" });
|
||||||
|
expect(await prisma.protokoll.count()).toBe(1);
|
||||||
|
fehler.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Protokoll ist ueber die Anwendung nicht aenderbar", () => {
|
||||||
|
function dateien(verzeichnis: string): string[] {
|
||||||
|
return readdirSync(verzeichnis).flatMap((name) => {
|
||||||
|
const pfad = path.join(verzeichnis, name);
|
||||||
|
if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
|
||||||
|
return /\.(ts|tsx|sql)$/.test(name) ? [pfad] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each(dateien("src"))("%s aendert und loescht keine Protokolleintraege", (datei) => {
|
||||||
|
const inhalt = readFileSync(datei, "utf8");
|
||||||
|
expect(inhalt).not.toMatch(/\.protokoll\s*\.\s*(update|updateMany|upsert|delete|deleteMany)\b/);
|
||||||
|
expect(inhalt).not.toMatch(/(UPDATE|DELETE\s+FROM|TRUNCATE)\s+"?Protokoll"?/i);
|
||||||
|
expect(inhalt).not.toMatch(/DISABLE\s+TRIGGER/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { createSocket, type Socket } from "node:dgram";
|
||||||
|
import { createServer, type Server } from "node:net";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
HOECHSTLAENGE,
|
||||||
|
sdWertEscapen,
|
||||||
|
syslogKonfigAus,
|
||||||
|
syslogNachricht,
|
||||||
|
syslogSender,
|
||||||
|
syslogZielText,
|
||||||
|
tcpRahmen,
|
||||||
|
type SyslogEintrag,
|
||||||
|
} from "@/lib/syslog";
|
||||||
|
|
||||||
|
/** Syslog-Weiterleitung (#027): Format nach RFC 5424, Escaping, Laenge, Versand. */
|
||||||
|
|
||||||
|
const EINTRAG: SyslogEintrag = {
|
||||||
|
id: 42n,
|
||||||
|
zeitpunkt: new Date("2026-10-12T14:03:07.123Z"),
|
||||||
|
akteur: "Florian",
|
||||||
|
aktion: "authorized_keys.geschrieben",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
hostName: "srv-pve01",
|
||||||
|
zielbenutzer: "root",
|
||||||
|
fingerprint: "SHA256:abc",
|
||||||
|
hash: "f00",
|
||||||
|
};
|
||||||
|
const KONFIG = { appName: "sshkv", facility: 10 };
|
||||||
|
const FEST = { rechner: "vault", prozess: 7 };
|
||||||
|
|
||||||
|
describe("syslogKonfigAus", () => {
|
||||||
|
it("ist ohne Host aus", () => {
|
||||||
|
expect(syslogKonfigAus({})).toBeNull();
|
||||||
|
expect(syslogKonfigAus({ SSHKV_SYSLOG_HOST: " " })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt Standardwerte", () => {
|
||||||
|
expect(syslogKonfigAus({ SSHKV_SYSLOG_HOST: "log.intern" })).toEqual({
|
||||||
|
host: "log.intern",
|
||||||
|
port: 514,
|
||||||
|
protokoll: "udp",
|
||||||
|
appName: "sshkv",
|
||||||
|
facility: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liest Port, Protokoll, App-Name und Facility", () => {
|
||||||
|
const konfig = syslogKonfigAus({
|
||||||
|
SSHKV_SYSLOG_HOST: "10.0.0.5",
|
||||||
|
SSHKV_SYSLOG_PORT: "6514",
|
||||||
|
SSHKV_SYSLOG_PROTOKOLL: "TCP",
|
||||||
|
SSHKV_SYSLOG_APPNAME: "vault prod",
|
||||||
|
SSHKV_SYSLOG_FACILITY: "local3",
|
||||||
|
});
|
||||||
|
expect(konfig).toMatchObject({ port: 6514, protokoll: "tcp", appName: "vault_prod", facility: 19 });
|
||||||
|
expect(syslogZielText(konfig!)).toBe("tcp://10.0.0.5:6514");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("faellt bei Unsinn auf die Standards zurueck", () => {
|
||||||
|
expect(
|
||||||
|
syslogKonfigAus({
|
||||||
|
SSHKV_SYSLOG_HOST: "x",
|
||||||
|
SSHKV_SYSLOG_PORT: "99999",
|
||||||
|
SSHKV_SYSLOG_FACILITY: "quatsch",
|
||||||
|
}),
|
||||||
|
).toMatchObject({ port: 514, facility: 10, protokoll: "udp" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syslogNachricht", () => {
|
||||||
|
it("baut eine Zeile nach RFC 5424 mit strukturierten Daten", () => {
|
||||||
|
expect(syslogNachricht(EINTRAG, KONFIG, FEST)).toBe(
|
||||||
|
"<85>1 2026-10-12T14:03:07.123Z vault sshkv 7 authorized_keys.geschrieben " +
|
||||||
|
'[sshkv@32473 id="42" akteur="Florian" aktion="authorized_keys.geschrieben" ergebnis="ERFOLG" ' +
|
||||||
|
'host="srv-pve01" zielbenutzer="root" fingerprint="SHA256:abc" hash="f00"] ' +
|
||||||
|
"Florian: authorized_keys.geschrieben ERFOLG auf srv-pve01",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leitet die Severity aus dem Ergebnis ab", () => {
|
||||||
|
// authpriv (10) * 8 = 80; notice 5, warning 4, err 3
|
||||||
|
expect(syslogNachricht({ ...EINTRAG, ergebnis: "VERWEIGERT" }, KONFIG, FEST)).toMatch(/^<84>1 /);
|
||||||
|
expect(syslogNachricht({ ...EINTRAG, ergebnis: "FEHLER" }, KONFIG, FEST)).toMatch(/^<83>1 /);
|
||||||
|
expect(syslogNachricht(EINTRAG, { appName: "sshkv", facility: 16 }, FEST)).toMatch(/^<133>1 /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapt Anfuehrungszeichen, Backslash und eckige Klammer", () => {
|
||||||
|
expect(sdWertEscapen('a"b\\c]d')).toBe('a\\"b\\\\c\\]d');
|
||||||
|
const zeile = syslogNachricht({ ...EINTRAG, akteur: 'Eve "]x', zielbenutzer: "a\nb" }, KONFIG, FEST);
|
||||||
|
expect(zeile).toContain('akteur="Eve \\"\\]x"');
|
||||||
|
expect(zeile).toContain('zielbenutzer="a b"');
|
||||||
|
expect(zeile).not.toContain("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst leere Felder weg", () => {
|
||||||
|
const zeile = syslogNachricht(
|
||||||
|
{ ...EINTRAG, hostName: null, zielbenutzer: null, fingerprint: undefined },
|
||||||
|
KONFIG,
|
||||||
|
FEST,
|
||||||
|
);
|
||||||
|
expect(zeile).not.toContain("host=");
|
||||||
|
expect(zeile).not.toContain("fingerprint=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bleibt unter der Hoechstlaenge, auch mit langen Werten", () => {
|
||||||
|
const lang = "ä".repeat(5000);
|
||||||
|
const zeile = syslogNachricht(
|
||||||
|
{ ...EINTRAG, akteur: lang, hostName: '"'.repeat(3000), zielbenutzer: lang, fingerprint: lang },
|
||||||
|
KONFIG,
|
||||||
|
FEST,
|
||||||
|
);
|
||||||
|
expect(Buffer.byteLength(zeile, "utf8")).toBeLessThanOrEqual(HOECHSTLAENGE);
|
||||||
|
// Strukturierte Daten bleiben vollstaendig und geschlossen.
|
||||||
|
expect(zeile).toMatch(/hash="f00"\] /);
|
||||||
|
// Kein halbes Escape am Ende eines Werts.
|
||||||
|
expect(zeile).not.toMatch(/[^\\]\\"\s/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rahmt fuer TCP mit Oktettzaehlung", () => {
|
||||||
|
expect(tcpRahmen("<85>1 ä").toString("utf8")).toBe("8 <85>1 ä");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("syslogSender", () => {
|
||||||
|
let empfaenger: Socket | undefined;
|
||||||
|
let server: Server | undefined;
|
||||||
|
afterEach(() => {
|
||||||
|
empfaenger?.close();
|
||||||
|
empfaenger = undefined;
|
||||||
|
server?.close();
|
||||||
|
server = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function udpEmpfaenger(): Promise<{ port: number; naechste: () => Promise<string> }> {
|
||||||
|
const socket = createSocket("udp4");
|
||||||
|
empfaenger = socket;
|
||||||
|
const eingang: string[] = [];
|
||||||
|
const wartende: ((text: string) => void)[] = [];
|
||||||
|
socket.on("message", (daten) => {
|
||||||
|
const text = daten.toString("utf8");
|
||||||
|
const warten = wartende.shift();
|
||||||
|
if (warten) warten(text);
|
||||||
|
else eingang.push(text);
|
||||||
|
});
|
||||||
|
await new Promise<void>((fertig) => socket.bind(0, "127.0.0.1", fertig));
|
||||||
|
return {
|
||||||
|
port: (socket.address() as AddressInfo).port,
|
||||||
|
naechste: () =>
|
||||||
|
new Promise((fertig) => {
|
||||||
|
const vorhanden = eingang.shift();
|
||||||
|
if (vorhanden) fertig(vorhanden);
|
||||||
|
else wartende.push(fertig);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("sendet per UDP an einen lokalen Empfaenger", async () => {
|
||||||
|
const { port, naechste } = await udpEmpfaenger();
|
||||||
|
const sender = syslogSender({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
protokoll: "udp",
|
||||||
|
appName: "sshkv",
|
||||||
|
facility: 10,
|
||||||
|
});
|
||||||
|
sender.senden(EINTRAG);
|
||||||
|
const zeile = await naechste();
|
||||||
|
sender.schliessen();
|
||||||
|
expect(zeile).toMatch(
|
||||||
|
/^<85>1 2026-10-12T14:03:07.123Z \S+ sshkv \d+ authorized_keys.geschrieben \[sshkv@32473 /,
|
||||||
|
);
|
||||||
|
expect(zeile).toContain('hash="f00"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sendet per TCP mit Rahmen", async () => {
|
||||||
|
const eingang = new Promise<string>((fertig) => {
|
||||||
|
server = createServer((verbindung) => {
|
||||||
|
verbindung.on("data", (daten) => fertig(daten.toString("utf8")));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise<void>((fertig) => server!.listen(0, "127.0.0.1", fertig));
|
||||||
|
const port = (server!.address() as AddressInfo).port;
|
||||||
|
const sender = syslogSender({
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port,
|
||||||
|
protokoll: "tcp",
|
||||||
|
appName: "sshkv",
|
||||||
|
facility: 10,
|
||||||
|
});
|
||||||
|
sender.senden(EINTRAG);
|
||||||
|
const text = await eingang;
|
||||||
|
sender.schliessen();
|
||||||
|
const [laenge, ...rest] = text.split(" ");
|
||||||
|
expect(Number(laenge)).toBe(Buffer.byteLength(rest.join(" "), "utf8"));
|
||||||
|
expect(rest.join(" ")).toMatch(/^<85>1 /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wirft nie und meldet Fehler hoechstens einmal je Minute", async () => {
|
||||||
|
const melden = vi.fn();
|
||||||
|
let uhr = 0;
|
||||||
|
// Port 1 auf localhost: niemand hoert zu, die Verbindung scheitert sofort.
|
||||||
|
const sender = syslogSender(
|
||||||
|
{ host: "127.0.0.1", port: 1, protokoll: "tcp", appName: "sshkv", facility: 10 },
|
||||||
|
melden,
|
||||||
|
() => uhr,
|
||||||
|
);
|
||||||
|
expect(() => sender.senden(EINTRAG)).not.toThrow();
|
||||||
|
await vi.waitFor(() => expect(melden).toHaveBeenCalledTimes(1));
|
||||||
|
uhr = 30_000;
|
||||||
|
sender.senden(EINTRAG);
|
||||||
|
await new Promise((fertig) => setTimeout(fertig, 100));
|
||||||
|
expect(melden).toHaveBeenCalledTimes(1);
|
||||||
|
uhr = 61_000;
|
||||||
|
sender.senden(EINTRAG);
|
||||||
|
await vi.waitFor(() => expect(melden).toHaveBeenCalledTimes(2));
|
||||||
|
sender.schliessen();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { aktionEinordnen, befundZusammenfassen, uebersicht } from "@/server/queries/uebersicht";
|
||||||
|
import { protokollieren } from "@/server/services/protokoll";
|
||||||
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
|
|
||||||
|
/** Übersicht (#026): Zahlen aus der Datenbank, nie hinterlegt. */
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Befunde und Aktionen", () => {
|
||||||
|
it("fasst Befunde lesbar zusammen", () => {
|
||||||
|
expect(befundZusammenfassen(["UNBEKANNTER_SCHLUESSEL"])).toBe("1 unbekannter Schlüssel");
|
||||||
|
expect(befundZusammenfassen(["UNBEKANNTER_SCHLUESSEL", "UNBEKANNTER_SCHLUESSEL", "RECHTE"])).toBe(
|
||||||
|
"2 unbekannte Schlüssel · Rechte falsch",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ordnet Aktionen fuer die Farbe ein", () => {
|
||||||
|
expect(aktionEinordnen("authorized_keys.geschrieben", "ERFOLG")).toBe("ausgerollt");
|
||||||
|
expect(aktionEinordnen("schluessel.widerruf.eingeleitet", "ERFOLG")).toBe("widerrufen");
|
||||||
|
expect(aktionEinordnen("schluessel.erzeugt", "ERFOLG")).toBe("erstellt");
|
||||||
|
expect(aktionEinordnen("anmeldung.notfall", "VERWEIGERT")).toBe("verweigert");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Kennzahlen", () => {
|
||||||
|
it("zaehlt Hosts, Schluessel, Abweichungen und Ablaeufe aus der Datenbank", async () => {
|
||||||
|
const jetzt = new Date("2026-10-12T10:00:00Z");
|
||||||
|
const tag = 86_400_000;
|
||||||
|
const a = await prisma.host.create({
|
||||||
|
data: { name: "srv-a", adresse: "10.0.0.1", driftZustand: "SYNCHRON", letzterAbgleichAm: jetzt },
|
||||||
|
});
|
||||||
|
const b = await prisma.host.create({
|
||||||
|
data: { name: "srv-b", adresse: "10.0.0.2", driftZustand: "DRIFT" },
|
||||||
|
});
|
||||||
|
await prisma.host.create({ data: { name: "srv-c", adresse: "10.0.0.3", erreichbar: false } });
|
||||||
|
await prisma.host.create({ data: { name: "srv-alt", adresse: "10.0.0.4", archiviertAm: jetzt } });
|
||||||
|
await prisma.abweichung.createMany({
|
||||||
|
data: [
|
||||||
|
{ hostId: b.id, zielbenutzer: "root", art: "UNBEKANNTER_SCHLUESSEL", fingerprint: "SHA256:x" },
|
||||||
|
{ hostId: b.id, zielbenutzer: "root", art: "RECHTE" },
|
||||||
|
{ hostId: a.id, zielbenutzer: "root", art: "RECHTE", behobenAm: jetzt },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let n = 0;
|
||||||
|
const key = (daten: Record<string, unknown>) =>
|
||||||
|
prisma.schluessel.create({
|
||||||
|
data: {
|
||||||
|
name: `k${++n}`,
|
||||||
|
slug: `k${n}`,
|
||||||
|
typ: "ED25519",
|
||||||
|
kategorie: "DIENST",
|
||||||
|
publicKey: "ssh-ed25519 AAAA",
|
||||||
|
fingerprint: `SHA256:k${n}`,
|
||||||
|
kommentar: "k",
|
||||||
|
...daten,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await key({ ablaufAm: new Date(jetzt.getTime() + 5 * tag) });
|
||||||
|
await key({ ablaufAm: new Date(jetzt.getTime() - 2 * tag) });
|
||||||
|
await key({ ablaufAm: new Date(jetzt.getTime() + 60 * tag) });
|
||||||
|
await key({ status: "WIDERRUFEN", ablaufAm: new Date(jetzt.getTime() + 1 * tag) });
|
||||||
|
await protokollieren({ akteur: "System", aktion: "abgleich", ergebnis: "ERFOLG", hostName: "srv-a" });
|
||||||
|
|
||||||
|
const u = await uebersicht(jetzt);
|
||||||
|
expect(u.kennzahlen.hosts).toEqual({ wert: 3, hinweis: "1 synchron · 1 nicht erreichbar" });
|
||||||
|
expect(u.kennzahlen.schluessel.wert).toBe(3);
|
||||||
|
expect(u.kennzahlen.abweichungen).toEqual({ wert: 2, hinweis: "auf 1 Host(s)" });
|
||||||
|
expect(u.kennzahlen.ablaufend).toEqual({ wert: 1, hinweis: "1 bereits abgelaufen" });
|
||||||
|
expect(u.mitAbweichung.map((m) => [m.host, m.befund])).toEqual([
|
||||||
|
["srv-b", "1 unbekannter Schlüssel · Rechte falsch"],
|
||||||
|
["srv-c", "nicht erreichbar"],
|
||||||
|
]);
|
||||||
|
expect(u.baldAblaufend.map((s) => s.zweck)).toEqual(["k2", "k1"]);
|
||||||
|
expect(u.letzterAbgleich).toEqual(jetzt);
|
||||||
|
expect(u.letzteAktionen[0]).toMatchObject({ art: "abgleich", benutzer: "System" });
|
||||||
|
});
|
||||||
|
});
|
||||||