feat(benachrichtigung): E-Mail und Webhook bei Drift und Ablauf (#028)

Drift, ablaufende und abgelaufene Schluessel, unerreichbare Hosts und
geaenderte Host-Keys sollen auffallen, ohne dass jemand taeglich in die
Oberflaeche schaut. Versand per SMTP (Microsoft 365, STARTTLS) und per
Webhook mit HMAC-Signatur, eingestellt unter /einstellungen.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:54:43 +02:00
co-authored by Claude Opus 5.5
parent 83bd9e0922
commit 8f5b31c0cb
26 changed files with 3243 additions and 12 deletions
+354
View File
@@ -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,
});
});
});