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:
@@ -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