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,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}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user