Merge branch 'phase/028-benachrichtigungen'
# Conflicts: # docker-compose.yml # scripts/screenshots.ts
This commit is contained in:
@@ -59,6 +59,10 @@ SSHKV_BREAKGLASS_PASSWORD=
|
||||
# --- Worker --------------------------------------------------------------------
|
||||
# Zeitplan fuer den Abgleich aller Hosts (cron)
|
||||
SSHKV_SYNC_CRON=30 2 * * *
|
||||
# Taegliche Zusammenfassung der Benachrichtigungen (cron, Europe/Berlin, #028). Geaenderte
|
||||
# Host-Keys meldet der Worker unabhaengig davon alle fuenf Minuten. Kanaele und Anlaesse
|
||||
# stellt ein Admin unter /einstellungen ein.
|
||||
SSHKV_MELDUNG_CRON=0 7 * * *
|
||||
|
||||
# --- Syslog (optional, #027) ---------------------------------------------------
|
||||
# Jeder Protokolleintrag geht zusaetzlich als RFC-5424-Zeile an einen Syslog-Server – die
|
||||
@@ -85,3 +89,8 @@ SSHKV_TEST_ROOT_PASSWORT=sshkv-test
|
||||
SSHKV_TEST_TEST_PASSWORT=sshkv-test
|
||||
# Mock-OIDC (Port-Bindung)
|
||||
SSHKV_TEST_OIDC=127.0.0.1:8090
|
||||
# Mailpit fuer tests/integration/benachrichtigung.test.ts (#028), getrennt zu starten:
|
||||
# docker run -d --name sshkv-mailpit -e MP_SMTP_DISABLE_RDNS=true \
|
||||
# -p 127.0.0.1:1026:1025 -p 127.0.0.1:8026:8025 axllent/mailpit
|
||||
SSHKV_TEST_SMTP=127.0.0.1:1026
|
||||
SSHKV_TEST_MAILPIT_API=http://127.0.0.1:8026
|
||||
|
||||
+8
-1
@@ -70,6 +70,9 @@ services:
|
||||
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
|
||||
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
|
||||
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
|
||||
# Nur zur Anzeige unter /einstellungen; ausgefuehrt werden die Zeitplaene im Worker.
|
||||
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
|
||||
SSHKV_MELDUNG_CRON: ${SSHKV_MELDUNG_CRON:-0 7 * * *}
|
||||
# Absichtlich KEIN Master-Key: eine Luecke im Webprozess allein entschluesselt keine
|
||||
# Private Keys. Nach aussen braucht er nur den OIDC-Anbieter.
|
||||
ports:
|
||||
@@ -106,9 +109,13 @@ services:
|
||||
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
|
||||
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
|
||||
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
|
||||
# Benachrichtigungen (#028): taegliche Zusammenfassung; Links in den Nachrichten zeigen
|
||||
# auf SSHKV_APP_URL. Den Weg zu Mailserver und Webhook-Ziel hat der Worker ueber "aussen".
|
||||
SSHKV_MELDUNG_CRON: ${SSHKV_MELDUNG_CRON:-0 7 * * *}
|
||||
SSHKV_APP_URL: ${SSHKV_APP_URL}
|
||||
secrets:
|
||||
- master_key
|
||||
# Der Worker braucht die Datenbank und den Weg zu den Hosts.
|
||||
# Der Worker braucht die Datenbank und den Weg zu den Hosts (und zu Mailserver/Webhook).
|
||||
networks: [datenbank, aussen]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f dist/worker.mjs > /dev/null"]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
@@ -1,19 +1,65 @@
|
||||
# #028 Benachrichtigungen
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.5.0
|
||||
**Labels:** backend
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0,5 h
|
||||
|
||||
## Ziel
|
||||
E-Mail (M365 SMTP) und Webhook bei Drift und Ablauf.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] Einstellungsseite `/einstellungen` statt Platzhalter: lesen alle Rollen, ändern nur
|
||||
`einstellungenVerwalten` (Admin). Abschnitte „Benachrichtigungen“, „Abgleich“ (Zeitpläne aus
|
||||
`SSHKV_SYNC_CRON`/`SSHKV_MELDUNG_CRON`, nur Anzeige) und „Instanz“ (Version,
|
||||
Deployment-Key-Fingerprint, OIDC-Anbieter, TOTP des Notfall-Admins). Platz für „Syslog“ markiert.
|
||||
- [x] Kanal E-Mail (SMTP): Vorbelegung Microsoft 365 (`smtp.office365.com:587`, STARTTLS), wahlweise
|
||||
TLS (465) oder unverschlüsselt (nur internes Relay, mit Warnung); Absender, Benutzer, Passwort,
|
||||
Empfängerliste.
|
||||
- [x] Kanal Webhook: JSON per POST, optional `X-Sshkv-Signatur: sha256=<HMAC>` über den Body; nur
|
||||
`https://`, `http://` nur per Häkchen mit Warnung; keine Weiterleitungen, Zeitlimit 15 s.
|
||||
- [x] Geheimnisse (SMTP-Passwort, Webhook-Geheimnis) mit `verschluesseln()` in `Einstellung`;
|
||||
die Oberfläche bekommt nur „gesetzt“, leeres Feld behält, Häkchen entfernt.
|
||||
- [x] „Testnachricht senden“ je Kanal als Vorgang `BENACHRICHTIGUNG_TEST` im Worker.
|
||||
- [x] Anlässe (je einzeln schaltbar): neue Abweichungen je Host, Ablauf in 30/14/7 Tagen,
|
||||
abgelaufen aber ausgerollt, Host nicht erreichbar – gesammelt in der täglichen Zusammenfassung
|
||||
(`SSHKV_MELDUNG_CRON`, Standard `0 7 * * *`); geänderter Host-Key sofort (Takt alle 5 Minuten).
|
||||
- [x] Versandprotokoll `Zustellung` (Migration `20261012161000_benachrichtigungen`) mit
|
||||
Anlass-Fingerabdruck, Kanal, Ergebnis, Fehler, Versuchen; Prüfung vor jedem Versand,
|
||||
Wiederholung nach 5/10/20/40 Minuten (höchstens 5 Versuche); „Letzte Zustellungen“ auf der Seite.
|
||||
- [x] Jede Zustellung in `protokollieren()` als `benachrichtigung.gesendet`/`…fehlgeschlagen`
|
||||
(Empfänger nur gezählt, Webhook nur Schema und Host).
|
||||
- [x] Zeitpläne in `src/worker/index.ts`; `.env.example` und `docker-compose.yml` ergänzt.
|
||||
- [x] Tests: Unit (Nachrichtenaufbau, Signatur, Stufen, Deduplizierung, Prüfungen), DB
|
||||
(Einstellungen, Versandprotokoll), Integration gegen Mailpit und lokalen HTTP-Empfänger,
|
||||
E2E `tests/e2e/einstellungen.spec.ts`; Screenshots `einstellungen-desktop/-mobil`.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] Admin speichert SMTP-Einstellungen, die Testnachricht kommt in Mailpit an (E2E).
|
||||
- [x] Das Passwort steht nie im Klartext in Datenbank, Protokoll oder Seite; das Feld zeigt nur „gesetzt“.
|
||||
- [x] Leser sieht die Seite ohne Ändern- und Test-Knöpfe.
|
||||
- [x] Dieselbe Abweichung, dieselbe Ablaufstufe und derselbe Ausfall werden je Kanal genau einmal gemeldet.
|
||||
- [x] Fehlgeschlagene Zustellungen werden begrenzt wiederholt und sind in der Oberfläche sichtbar.
|
||||
- [x] Ein geänderter Host-Key wird innerhalb von fünf Minuten gemeldet, ohne den Abgleich-Ablauf zu ändern.
|
||||
|
||||
## Abhängigkeiten
|
||||
#021
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Versand im Worker**, auch für die Testnachricht: der Webprozess braucht so keinen Weg zu
|
||||
Mailserver oder Webhook-Ziel, und regulärer Versand und Test laufen über denselben Code. Die
|
||||
Oberfläche verfolgt den Test wie andere Vorgänge über `VorgangsKnopf`.
|
||||
- **Anlässe aus der Datenbank**, nicht aus dem Abgleich: `src/worker/ablaeufe/abgleich.ts` bleibt
|
||||
unverändert; der Sammler liest offene `Abweichung`, `Host.erreichbar = false` und Ablaufdaten.
|
||||
Ein verpasster Lauf holt sich der nächste.
|
||||
- **Fingerabdrücke:** `drift:<abweichungId>`, `hostkey:<abweichungId>`,
|
||||
`ablauf:<schluesselId>:<stufe>:<ablaufdatum>` (mit Datum, damit ein verlängerter Schlüssel neu
|
||||
erinnert wird), `abgelaufen:<schluesselId>:<ablaufdatum>`,
|
||||
`unerreichbar:<hostId>:<letzterAbgleichAm>` (ändert sich nur bei gelungenem Abgleich → eine Meldung
|
||||
je Ausfall). Statt „seit der letzten Meldung“ zählt das Versandprotokoll – wer Benachrichtigungen
|
||||
erstmals einschaltet, bekommt die offenen Abweichungen einmal gemeldet.
|
||||
- Hosts mit offenem `HOSTKEY_GEAENDERT` erscheinen nicht zusätzlich als „nicht erreichbar“.
|
||||
- Der Fünf-Minuten-Takt verschickt dringende Host-Key-Meldungen und fällige Wiederholungen;
|
||||
Neues aus (a)–(d) wartet auf die Zusammenfassung. Läufe im selben Worker laufen nacheinander.
|
||||
- Leser sehen statt der Webhook-Adresse nur Schema und Host (Tokens im Pfad).
|
||||
- Mailpit für Tests mit `MP_SMTP_DISABLE_RDNS=true` starten, sonst wartet es je Verbindung 5 s.
|
||||
- `nodemailer@10.0.10` wie in ProxLedger, exakt gepinnt.
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"jose": "^6.2.12",
|
||||
"lucide-react": "1.47.0",
|
||||
"next": "16.3.6",
|
||||
"nodemailer": "10.0.10",
|
||||
"openid-client": "^6.8.8",
|
||||
"pg": "^8.23.1",
|
||||
"pg-boss": "^12.37.1",
|
||||
@@ -71,6 +72,7 @@
|
||||
"@playwright/test": "1.63.0",
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/node": "^24.19.2",
|
||||
"@types/nodemailer": "8.0.2",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.3.0",
|
||||
|
||||
Generated
+19
@@ -199,6 +199,9 @@ importers:
|
||||
next:
|
||||
specifier: 16.3.6
|
||||
version: 16.3.6(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.63.0)(@types/node@24.19.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
|
||||
nodemailer:
|
||||
specifier: 10.0.10
|
||||
version: 10.0.10
|
||||
openid-client:
|
||||
specifier: ^6.8.8
|
||||
version: 6.8.8
|
||||
@@ -239,6 +242,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^24.19.2
|
||||
version: 24.19.2
|
||||
'@types/nodemailer':
|
||||
specifier: 8.0.2
|
||||
version: 8.0.2
|
||||
'@types/pg':
|
||||
specifier: ^8.23.1
|
||||
version: 8.23.1
|
||||
@@ -1481,6 +1487,9 @@ packages:
|
||||
'@types/node@26.6.5':
|
||||
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
|
||||
|
||||
'@types/nodemailer@8.0.2':
|
||||
resolution: {integrity: sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==}
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==}
|
||||
|
||||
@@ -3067,6 +3076,10 @@ packages:
|
||||
resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
nodemailer@10.0.10:
|
||||
resolution: {integrity: sha512-He9XskOFms62SyAKkLu8CcGcYHAo+BSHSsORI8s4PJH8qZgZY4L4lDfvyN0twvmbpyG+eGrxpyBlskj9d9rJ8A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
non-error@0.1.0:
|
||||
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -4910,6 +4923,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 8.9.0
|
||||
|
||||
'@types/nodemailer@8.0.2':
|
||||
dependencies:
|
||||
'@types/node': 24.19.2
|
||||
|
||||
'@types/pg@8.23.1':
|
||||
dependencies:
|
||||
'@types/node': 26.6.5
|
||||
@@ -6625,6 +6642,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.58: {}
|
||||
|
||||
nodemailer@10.0.10: {}
|
||||
|
||||
non-error@0.1.0: {}
|
||||
|
||||
oauth4webapi@3.8.8: {}
|
||||
|
||||
@@ -0,0 +1,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
|
||||
/// sudoers und – falls von sshkv angelegt – den Benutzer entfernen (#018, #024).
|
||||
ZIELKONTO_ENTFERNEN
|
||||
/// Testnachricht ueber einen Benachrichtigungskanal (#028).
|
||||
BENACHRICHTIGUNG_TEST
|
||||
}
|
||||
|
||||
enum VorgangsStatus {
|
||||
@@ -125,6 +127,17 @@ enum VorgangsStatus {
|
||||
ABGEBROCHEN
|
||||
}
|
||||
|
||||
/// Versandweg einer Benachrichtigung (#028).
|
||||
enum MeldungsKanal {
|
||||
EMAIL
|
||||
WEBHOOK
|
||||
}
|
||||
|
||||
enum ZustellErgebnis {
|
||||
GESENDET
|
||||
FEHLGESCHLAGEN
|
||||
}
|
||||
|
||||
enum ProtokollErgebnis {
|
||||
ERFOLG
|
||||
FEHLER
|
||||
@@ -496,6 +509,30 @@ model Einstellung {
|
||||
geaendertAm DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// Versandprotokoll der Benachrichtigungen (#028): je Anlass und Kanal hoechstens eine Zeile.
|
||||
/// Vor jedem Versand wird hiergegen geprueft – so meldet sshkv dieselbe Abweichung oder dieselbe
|
||||
/// Ablaufstufe nie zweimal, auch wenn ein Zeitplan doppelt feuert oder der Worker neu startet.
|
||||
model Zustellung {
|
||||
id String @id @default(cuid())
|
||||
/// Fingerabdruck des Anlasses, z. B. drift:<abweichungId>, ablauf:<schluesselId>:7:<datum>.
|
||||
anlass String
|
||||
kanal MeldungsKanal
|
||||
ergebnis ZustellErgebnis
|
||||
/// Kurzer Fehlertext ohne Zugangsdaten.
|
||||
fehler String?
|
||||
versuche Int @default(1)
|
||||
/// Wann ein Fehlschlag fruehestens erneut versucht wird; leer = nicht mehr.
|
||||
naechsterVersuchAm DateTime?
|
||||
/// Gemeinsame Kennung aller Anlaesse einer Nachricht – die Oberflaeche zeigt je Nachricht eine Zeile.
|
||||
sendung String
|
||||
betreff String
|
||||
zeitpunkt DateTime @default(now())
|
||||
|
||||
@@unique([anlass, kanal])
|
||||
@@index([zeitpunkt])
|
||||
@@index([sendung])
|
||||
}
|
||||
|
||||
/// Einfaches Rate-Limit fuer Anmeldung und Re-Auth (feste Fenster).
|
||||
model Ratenbegrenzung {
|
||||
schluessel String @id
|
||||
|
||||
@@ -51,6 +51,8 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
|
||||
},
|
||||
// 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 = [
|
||||
|
||||
@@ -1,18 +1,196 @@
|
||||
import { Inbox } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
|
||||
import type { ReactNode } from "react";
|
||||
import { BenachrichtigungFormular } from "@/components/einstellungen/BenachrichtigungFormular";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
|
||||
import { datumZeit } from "@/lib/format";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { einstellungenSeite, type Zeitplan, type ZustellungZeile } from "@/server/queries/einstellungen";
|
||||
|
||||
export const metadata: Metadata = { title: "Einstellungen" };
|
||||
|
||||
export default async function Einstellungen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
await sitzungErforderlich();
|
||||
function Info({ titel, children }: { titel: string; children: ReactNode }) {
|
||||
return (
|
||||
<Platzhalterseite
|
||||
titel="Einstellungen"
|
||||
untertitel="Deployment-Key, eigene Vorlagen, Benachrichtigungen"
|
||||
phase={3}
|
||||
issue="#019"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
|
||||
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Abschnitt({ titel, id, children }: { titel: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<section aria-labelledby={id} className="flex min-w-0 flex-col gap-4">
|
||||
<h2 id={id} className="text-muted text-[12.5px] font-semibold tracking-[0.08em] uppercase">
|
||||
{titel}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ZeitplanAnzeige({ plan }: { plan: Zeitplan }) {
|
||||
return (
|
||||
<span className="flex flex-col gap-0.5">
|
||||
{plan.beschreibung && <span>{plan.beschreibung}</span>}
|
||||
<code className="text-muted font-mono text-[13px]">{plan.ausdruck}</code>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const KANAL_TEXT = { EMAIL: "E-Mail", WEBHOOK: "Webhook" } as const;
|
||||
|
||||
function Zustellungen({ zeilen }: { zeilen: readonly ZustellungZeile[] }) {
|
||||
if (zeilen.length === 0) {
|
||||
return (
|
||||
<Leerzustand
|
||||
icon={<Inbox className="size-5" />}
|
||||
titel="Noch nichts versendet"
|
||||
text="Hier erscheinen die letzten Zustellungen samt Ergebnis – auch Testnachrichten."
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tabelle
|
||||
beschriftung="Letzte Zustellungen"
|
||||
spalten={[{ titel: "Zeitpunkt" }, { titel: "Kanal" }, { titel: "Nachricht" }, { titel: "Ergebnis" }]}
|
||||
>
|
||||
{zeilen.map((z) => (
|
||||
<TabellenZeile key={z.sendung}>
|
||||
<TabellenZelle className="whitespace-nowrap">
|
||||
<time dateTime={z.zeitpunkt.toISOString()}>{datumZeit(z.zeitpunkt)}</time>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="whitespace-nowrap">{KANAL_TEXT[z.kanal]}</TabellenZelle>
|
||||
<TabellenZelle className="min-w-[16rem]">
|
||||
<span className="block">{z.betreff}</span>
|
||||
<span className="text-muted block text-[12.5px]">
|
||||
{z.anlaesse === 1 ? "1 Anlass" : `${z.anlaesse} Anlässe`}
|
||||
{z.versuche > 1 && ` · ${z.versuche}. Versuch`}
|
||||
</span>
|
||||
</TabellenZelle>
|
||||
<TabellenZelle className="min-w-[12rem]">
|
||||
{z.ergebnis === "GESENDET" ? (
|
||||
<Badge variante="ok">gesendet</Badge>
|
||||
) : (
|
||||
<span className="flex flex-col items-start gap-1">
|
||||
<Badge variante="gefahr">fehlgeschlagen</Badge>
|
||||
{z.fehler && <span className="text-danger text-[12.5px] break-words">{z.fehler}</span>}
|
||||
<span className="text-muted text-[12.5px]">
|
||||
{z.naechsterVersuchAm
|
||||
? `neuer Versuch ab ${datumZeit(z.naechsterVersuchAm)}`
|
||||
: "kein weiterer Versuch"}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</TabellenZelle>
|
||||
</TabellenZeile>
|
||||
))}
|
||||
</Tabelle>
|
||||
);
|
||||
}
|
||||
|
||||
/** Einstellungen (#028): lesen duerfen alle, aendern nur `einstellungenVerwalten` (Admin). */
|
||||
export default async function Einstellungen() {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
const { benutzer } = await sitzungErforderlich("lesen", "/einstellungen");
|
||||
const darfAendern = darf(benutzer.rolle, "einstellungenVerwalten");
|
||||
const seite = await einstellungenSeite();
|
||||
// Eine Webhook-Adresse traegt oft ein Token im Pfad – Leser sehen nur Schema und Host.
|
||||
const ansicht = darfAendern
|
||||
? seite.benachrichtigung
|
||||
: {
|
||||
...seite.benachrichtigung,
|
||||
webhook: { ...seite.benachrichtigung.webhook, url: seite.webhookZiel ?? "" },
|
||||
};
|
||||
const { instanz } = seite;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Einstellungen"
|
||||
untertitel={
|
||||
darfAendern
|
||||
? "Benachrichtigungen, Zeitpläne und Angaben zu dieser Instanz"
|
||||
: "Nur Ansicht – ändern dürfen Administratoren."
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid min-w-0 gap-6 sm:gap-7 xl:grid-cols-[minmax(0,1fr)_22rem] xl:items-start">
|
||||
<Abschnitt titel="Benachrichtigungen" id="abschnitt-benachrichtigungen">
|
||||
<BenachrichtigungFormular ansicht={ansicht} darfAendern={darfAendern} />
|
||||
<Karte
|
||||
titel="Letzte Zustellungen"
|
||||
untertitel="Fehlgeschlagene Zustellungen wiederholt der Worker bis zu fünfmal."
|
||||
>
|
||||
<Zustellungen zeilen={seite.zustellungen} />
|
||||
</Karte>
|
||||
</Abschnitt>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||
<Abschnitt titel="Abgleich" id="abschnitt-abgleich">
|
||||
<Karte as="div">
|
||||
<dl className="grid gap-4">
|
||||
<Info titel="Abgleich aller Hosts">
|
||||
<ZeitplanAnzeige plan={seite.abgleich} />
|
||||
</Info>
|
||||
<Info titel="Tägliche Zusammenfassung">
|
||||
<ZeitplanAnzeige plan={seite.meldung} />
|
||||
</Info>
|
||||
<Info titel="Geänderte Host-Keys">alle 5 Minuten geprüft</Info>
|
||||
</dl>
|
||||
<p className="text-muted mt-4 text-[13px]">
|
||||
Zeitzone Europe/Berlin. Gesetzt über <code className="font-mono">SSHKV_SYNC_CRON</code> und{" "}
|
||||
<code className="font-mono">SSHKV_MELDUNG_CRON</code> in der Umgebung des Workers.
|
||||
</p>
|
||||
</Karte>
|
||||
</Abschnitt>
|
||||
|
||||
{/* Platz fuer den Abschnitt „Syslog“ – folgt in einem eigenen Issue. */}
|
||||
|
||||
<Abschnitt titel="Instanz" id="abschnitt-instanz">
|
||||
<Karte as="div">
|
||||
<dl className="grid gap-4">
|
||||
<Info titel="Version">
|
||||
<span className="font-mono text-[13.5px]">{instanz.version}</span>
|
||||
</Info>
|
||||
<Info titel="Deployment-Key">
|
||||
{instanz.deploymentKey ? (
|
||||
<MonoKopieren
|
||||
wert={instanz.deploymentKey.fingerprint}
|
||||
beschriftung="Fingerprint des Deployment-Keys"
|
||||
umbrechen
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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,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,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,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 } });
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import { PgBoss } from "pg-boss";
|
||||
/** Eine Queue je Vorgangsart haelt Wiederholungen und Gleichzeitigkeit getrennt einstellbar. */
|
||||
export const WARTESCHLANGE = "sshkv-vorgang";
|
||||
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`. */
|
||||
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,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,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HandlerTabelle } from "@/worker/ausfuehren";
|
||||
import { abgleichHandler } from "@/worker/ablaeufe/abgleich";
|
||||
import { benachrichtigungTestHandler } from "@/worker/ablaeufe/benachrichtigung";
|
||||
import { ausrollenHandler, trockenlaufHandler } from "@/worker/ablaeufe/ausrollen";
|
||||
import { bootstrapHandler } from "@/worker/ablaeufe/bootstrap";
|
||||
import { erreichbarkeitHandler, hostKeyAbrufenHandler } from "@/worker/ablaeufe/hostkey";
|
||||
@@ -25,4 +26,5 @@ export const HANDLER: HandlerTabelle = {
|
||||
ABGLEICH: abgleichHandler,
|
||||
ZIELKONTO_ANLEGEN: zielkontoAnlegenHandler,
|
||||
ZIELKONTO_ENTFERNEN: zielkontoEntfernenHandler,
|
||||
BENACHRICHTIGUNG_TEST: benachrichtigungTestHandler,
|
||||
};
|
||||
|
||||
+29
-1
@@ -5,11 +5,14 @@ import { alleHostsAbgleichen } from "@/server/services/abgleich";
|
||||
import {
|
||||
WARTESCHLANGE,
|
||||
ZEITPLAN_ABGLEICH,
|
||||
ZEITPLAN_MELDUNG,
|
||||
ZEITPLAN_MELDUNG_TAKT,
|
||||
warteschlangeHolen,
|
||||
type VorgangsJob,
|
||||
} from "@/server/vorgaenge/warteschlange";
|
||||
import { vorgangAusfuehren } from "@/worker/ausfuehren";
|
||||
import { abholungenAufraeumen } from "@/worker/ablaeufe/privatekey";
|
||||
import { taktVersenden, zusammenfassungVersenden } from "@/worker/benachrichtigung/versand";
|
||||
import { deploymentKeySicherstellen } from "@/worker/geheimnisse";
|
||||
import { HANDLER } from "@/worker/handler";
|
||||
|
||||
@@ -61,7 +64,32 @@ async function starten(): Promise<void> {
|
||||
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) => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user