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
+197
View File
@@ -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 &lt;script&gt;alert(1)&lt;/script&gt;");
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}`);
});
});