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
+346
View File
@@ -0,0 +1,346 @@
import { createHmac } from "node:crypto";
import { datum, datumZeit } from "@/lib/format";
import { PRODUKTNAME } from "@/lib/version";
/**
* Nachrichtenaufbau der Benachrichtigungen (#028): Betreff, Text- und HTML-Mail auf Deutsch
* und die JSON-Nutzlast des Webhooks – aus denselben Daten, damit beide Wege dasselbe sagen.
*
* Alles, was in HTML landet, stammt teils von den Hosts (Kommentare in authorized_keys,
* Benutzernamen) und wird deshalb maskiert. Schlichtes HTML mit Inline-Stilen, weil
* E-Mail-Programme Stylesheets ignorieren (Muster aus ProxLedger).
*/
export const ART_TEXT: Readonly<Record<string, string>> = {
UNBEKANNTER_SCHLUESSEL: "Unbekannter Schlüssel",
FEHLENDER_SCHLUESSEL: "Schlüssel fehlt",
ZUSAETZLICH_IM_BLOCK: "Block von Hand geändert",
DATEI_GEAENDERT: "Block beschädigt",
RECHTE: "Rechte",
HOSTKEY_GEAENDERT: "Host-Key geändert",
PRIVATE_KEY_FEHLT: "Private Key fehlt/verändert",
};
export type Befund = {
readonly art: string;
readonly zielbenutzer: string | null;
readonly fingerprint: string | null;
readonly text: string | null;
};
export type HostDrift = {
readonly hostId: string;
readonly name: string;
readonly adresse: string;
readonly befunde: readonly Befund[];
};
export type HostUnerreichbar = {
readonly hostId: string;
readonly name: string;
readonly adresse: string;
readonly zuletztGeprueftAm: Date | null;
readonly letzterAbgleichAm: Date | null;
};
export type SchluesselAblauf = {
readonly schluesselId: string;
readonly name: string;
readonly fingerprint: string;
readonly ablaufAm: Date;
readonly tage: number;
readonly stufe: number;
};
export type SchluesselAbgelaufen = {
readonly schluesselId: string;
readonly name: string;
readonly fingerprint: string;
readonly ablaufAm: Date;
readonly hosts: readonly string[];
};
export type Zusammenfassung = {
readonly drift: readonly HostDrift[];
readonly unerreichbar: readonly HostUnerreichbar[];
readonly ablauf: readonly SchluesselAblauf[];
readonly abgelaufen: readonly SchluesselAbgelaufen[];
};
export type HostKeyAenderung = {
readonly hostId: string;
readonly name: string;
readonly adresse: string;
readonly befunde: readonly { algorithmus: string; erwartet: string | null; gesehen: string }[];
};
export type WebhookNutzlast = {
readonly ereignis: "zusammenfassung" | "hostkey.geaendert" | "test";
readonly zeitstempel: string;
readonly instanz: string;
readonly [weiteres: string]: unknown;
};
export type Nachricht = {
readonly betreff: string;
readonly text: string;
readonly html: string;
readonly nutzlast: WebhookNutzlast;
};
export type Rahmen = { readonly appUrl: string; readonly jetzt: Date };
// ---------------------------------------------------------------- Hilfen
export function html(text: string): string {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function basis(appUrl: string): string {
return appUrl.replace(/\/+$/, "");
}
function mehrzahl(anzahl: number, eins: string, viele: string): string {
return `${anzahl} ${anzahl === 1 ? eins : viele}`;
}
function htmlRahmen(inhalt: string, appUrl: string, linkText: string, pfad: string): string {
return `<!doctype html><html lang="de"><body style="margin:0;padding:24px;background:#ffffff">
<div style="font-family:'IBM Plex Sans',Arial,sans-serif;font-size:15px;line-height:1.5;color:#1d232b;max-width:680px">
<div style="border-bottom:3px solid #b08a3e;padding-bottom:8px;margin-bottom:16px">
<span style="font-size:20px;font-weight:600">${PRODUKTNAME}</span>
<span style="color:#5d6672"> · Benachrichtigung</span>
</div>
${inhalt}
<p style="color:#5d6672;font-size:13px;margin-top:24px">
<a href="${html(basis(appUrl) + pfad)}" style="color:#8a6a2a">${html(linkText)}</a>
</p>
</div></body></html>`;
}
function befundZeile(b: Befund): string {
const teile = [ART_TEXT[b.art] ?? b.art];
if (b.zielbenutzer) teile.push(`Benutzer ${b.zielbenutzer}`);
if (b.fingerprint) teile.push(b.fingerprint);
if (b.text) teile.push(b.text);
return teile.join(" – ");
}
function befundJson(b: Befund) {
return {
art: b.art,
artText: ART_TEXT[b.art] ?? b.art,
zielbenutzer: b.zielbenutzer,
fingerprint: b.fingerprint,
text: b.text,
};
}
const H3 = 'style="font-size:16px;margin:20px 0 6px"';
const UL = 'style="margin:4px 0 12px;padding-left:20px"';
const MONO = "font-family:'JetBrains Mono',Consolas,monospace;font-size:13px";
// ---------------------------------------------------------------- Zusammenfassung
export function zusammenfassungLeer(z: Zusammenfassung): boolean {
return z.drift.length + z.unerreichbar.length + z.ablauf.length + z.abgelaufen.length === 0;
}
/** Tägliche Zusammenfassung der Anlaesse (a)–(d). */
export function zusammenfassungNachricht(z: Zusammenfassung, rahmen: Rahmen): Nachricht {
const befundAnzahl = z.drift.reduce((summe, h) => summe + h.befunde.length, 0);
const kopf: string[] = [];
if (befundAnzahl > 0) kopf.push(mehrzahl(befundAnzahl, "neue Abweichung", "neue Abweichungen"));
if (z.unerreichbar.length > 0)
kopf.push(mehrzahl(z.unerreichbar.length, "Host nicht erreichbar", "Hosts nicht erreichbar"));
if (z.ablauf.length > 0) kopf.push(mehrzahl(z.ablauf.length, "Schlüssel läuft ab", "Schlüssel laufen ab"));
if (z.abgelaufen.length > 0)
kopf.push(
mehrzahl(z.abgelaufen.length, "abgelaufener Schlüssel ausgerollt", "abgelaufene Schlüssel ausgerollt"),
);
const betreff = `[sshkv] Zusammenfassung: ${kopf.join(", ") || "keine neuen Meldungen"}`;
const url = basis(rahmen.appUrl);
const text: string[] = [`${PRODUKTNAME} – Zusammenfassung vom ${datumZeit(rahmen.jetzt)}`, ""];
const teile: string[] = [
`<h2 style="font-size:18px;margin:0 0 8px">Zusammenfassung vom ${html(datumZeit(rahmen.jetzt))}</h2>`,
];
if (z.drift.length > 0) {
text.push("Neue Abweichungen (Drift):");
teile.push(`<h3 ${H3}>Neue Abweichungen (Drift)</h3>`);
for (const host of z.drift) {
text.push(` ${host.name} (${host.adresse}):`);
for (const b of host.befunde) text.push(` · ${befundZeile(b)}`);
text.push(` ${url}/hosts/${host.hostId}`);
teile.push(
`<p style="margin:8px 0 2px"><a href="${html(`${url}/hosts/${host.hostId}`)}" style="color:#1d232b;font-weight:600;${MONO}">${html(host.name)}</a> <span style="color:#5d6672">${html(host.adresse)}</span></p>`,
`<ul ${UL}>${host.befunde.map((b) => `<li>${html(befundZeile(b))}</li>`).join("")}</ul>`,
);
}
text.push("");
}
if (z.unerreichbar.length > 0) {
text.push("Hosts nicht erreichbar:");
teile.push(`<h3 ${H3}>Hosts nicht erreichbar</h3><ul ${UL}>`);
for (const host of z.unerreichbar) {
const zuletzt = host.letzterAbgleichAm
? `letzter erfolgreicher Abgleich ${datumZeit(host.letzterAbgleichAm)}`
: "noch nie erfolgreich abgeglichen";
text.push(` · ${host.name} (${host.adresse}) – ${zuletzt}`);
teile.push(
`<li><span style="${MONO}">${html(host.name)}</span> (${html(host.adresse)}) – ${html(zuletzt)}</li>`,
);
}
teile.push("</ul>");
text.push("");
}
if (z.ablauf.length > 0) {
text.push("Bald ablaufende Schlüssel:");
teile.push(`<h3 ${H3}>Bald ablaufende Schlüssel</h3><ul ${UL}>`);
for (const s of z.ablauf) {
const wann = `läuft am ${datum(s.ablaufAm)} ab (${s.tage === 1 ? "in 1 Tag" : `in ${s.tage} Tagen`})`;
text.push(` · ${s.name} – ${wann}`, ` ${s.fingerprint}`);
teile.push(
`<li>${html(s.name)} – ${html(wann)}<br><span style="color:#5d6672;${MONO}">${html(s.fingerprint)}</span></li>`,
);
}
teile.push("</ul>");
text.push("");
}
if (z.abgelaufen.length > 0) {
text.push("Abgelaufen, aber noch ausgerollt:");
teile.push(`<h3 ${H3}>Abgelaufen, aber noch ausgerollt</h3><ul ${UL}>`);
for (const s of z.abgelaufen) {
const wo = s.hosts.length > 0 ? `noch auf ${s.hosts.join(", ")}` : "noch ausgerollt";
text.push(` · ${s.name} – abgelaufen am ${datum(s.ablaufAm)}, ${wo}`, ` ${s.fingerprint}`);
teile.push(
`<li>${html(s.name)} – abgelaufen am ${html(datum(s.ablaufAm))}, ${html(wo)}<br><span style="color:#5d6672;${MONO}">${html(s.fingerprint)}</span></li>`,
);
}
teile.push("</ul>");
text.push("");
}
text.push(`Übersicht: ${url}/`);
return {
betreff,
text: text.join("\n"),
html: htmlRahmen(teile.join("\n"), rahmen.appUrl, "Übersicht öffnen", "/"),
nutzlast: {
ereignis: "zusammenfassung",
zeitstempel: rahmen.jetzt.toISOString(),
instanz: url,
drift: z.drift.map((h) => ({
host: { id: h.hostId, name: h.name, adresse: h.adresse },
befunde: h.befunde.map(befundJson),
})),
unerreichbar: z.unerreichbar.map((h) => ({
host: { id: h.hostId, name: h.name, adresse: h.adresse },
zuletztGeprueftAm: h.zuletztGeprueftAm?.toISOString() ?? null,
letzterAbgleichAm: h.letzterAbgleichAm?.toISOString() ?? null,
})),
ablauf: z.ablauf.map((s) => ({
schluessel: { id: s.schluesselId, name: s.name, fingerprint: s.fingerprint },
ablaufAm: s.ablaufAm.toISOString(),
tage: s.tage,
stufe: s.stufe,
})),
abgelaufen: z.abgelaufen.map((s) => ({
schluessel: { id: s.schluesselId, name: s.name, fingerprint: s.fingerprint },
ablaufAm: s.ablaufAm.toISOString(),
hosts: [...s.hosts],
})),
},
};
}
// ---------------------------------------------------------------- Host-Key (sofort)
/** Dringende Einzelmeldung (e) je Host. */
export function hostKeyNachricht(h: HostKeyAenderung, rahmen: Rahmen): Nachricht {
const url = `${basis(rahmen.appUrl)}/hosts/${h.hostId}`;
const betreff = `[sshkv] DRINGEND: Host-Key von ${h.name} hat sich geändert`;
const zeilen = h.befunde.map(
(b) => `${b.algorithmus}: erwartet ${b.erwartet ?? "(unbekannt)"}, gesehen ${b.gesehen}`,
);
const text = [
`Der Host-Key von ${h.name} (${h.adresse}) hat sich geändert.`,
"",
...zeilen.map((z) => ` · ${z}`),
"",
"sshkv verbindet sich mit diesem Host nicht mehr, bis jemand den neuen Key geprüft und",
"bestätigt hat. Ist die Änderung nicht erklärbar (Neuinstallation, Austausch), kann ein",
"Angreifer zwischen sshkv und dem Host sitzen.",
"",
url,
].join("\n");
const inhalt = `<p style="margin:0 0 8px"><span style="display:inline-block;padding:2px 10px;border-radius:999px;background:#b3261e;color:#ffffff;font-size:13px;font-weight:600">Dringend</span></p>
<h2 style="font-size:18px;margin:8px 0">Host-Key von <span style="${MONO};font-size:17px">${html(h.name)}</span> geändert</h2>
<p style="margin:0 0 8px">${html(h.adresse)}</p>
<ul ${UL}>${zeilen.map((z) => `<li style="${MONO}">${html(z)}</li>`).join("")}</ul>
<p style="margin:0">sshkv verbindet sich mit diesem Host nicht mehr, bis jemand den neuen Key geprüft und bestätigt hat. Ist die Änderung nicht erklärbar (Neuinstallation, Austausch), kann ein Angreifer zwischen sshkv und dem Host sitzen.</p>`;
return {
betreff,
text,
html: htmlRahmen(inhalt, rahmen.appUrl, "Host öffnen und Key prüfen", `/hosts/${h.hostId}`),
nutzlast: {
ereignis: "hostkey.geaendert",
dringend: true,
zeitstempel: rahmen.jetzt.toISOString(),
instanz: basis(rahmen.appUrl),
host: { id: h.hostId, name: h.name, adresse: h.adresse },
befunde: h.befunde.map((b) => ({
art: "HOSTKEY_GEAENDERT",
artText: ART_TEXT["HOSTKEY_GEAENDERT"],
algorithmus: b.algorithmus,
erwartet: b.erwartet,
fingerprint: b.gesehen,
})),
},
};
}
// ---------------------------------------------------------------- Test
export function testNachricht(kanal: "EMAIL" | "WEBHOOK", rahmen: Rahmen): Nachricht {
const weg = kanal === "EMAIL" ? "E-Mail" : "Webhook";
const satz = `Diese Testnachricht bestätigt, dass der Versand per ${weg} funktioniert.`;
return {
betreff: "[sshkv] Testnachricht",
text: `${satz}\nGesendet am ${datumZeit(rahmen.jetzt)}.\n\n${basis(rahmen.appUrl)}/einstellungen`,
html: htmlRahmen(
`<h2 style="font-size:18px;margin:0 0 8px">Testnachricht</h2><p style="margin:0">${html(satz)}</p><p style="color:#5d6672;margin:8px 0 0">Gesendet am ${html(datumZeit(rahmen.jetzt))}.</p>`,
rahmen.appUrl,
"Einstellungen öffnen",
"/einstellungen",
),
nutzlast: {
ereignis: "test",
zeitstempel: rahmen.jetzt.toISOString(),
instanz: basis(rahmen.appUrl),
text: satz,
},
};
}
// ---------------------------------------------------------------- Webhook
/** Der Body wird genau einmal serialisiert – signiert wird, was auch gesendet wird. */
export function webhookKoerper(nachricht: Nachricht): string {
return JSON.stringify({ betreff: nachricht.betreff, ...nachricht.nutzlast });
}
/** `X-Sshkv-Signatur: sha256=<hex>` – HMAC-SHA256 ueber den rohen Body. */
export function signatur(koerper: string, geheimnis: string): string {
return `sha256=${createHmac("sha256", geheimnis).update(koerper, "utf8").digest("hex")}`;
}