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
+200
View File
@@ -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 &lt;b&gt;fett&lt;/b&gt;");
// 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);
});
});