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
+163
View File
@@ -0,0 +1,163 @@
import { tageBis } from "@/lib/format";
/**
* Regeln der Benachrichtigungen (#028), rein und ohne Datenbank – damit Stufen,
* Fingerabdruecke und Wiederholungen als Unit-Test festgehalten werden koennen.
*/
/** Die fuenf Anlaesse; je Anlass in den Einstellungen ein- und ausschaltbar. */
export const ANLAESSE = ["drift", "ablauf", "abgelaufen", "unerreichbar", "hostkey"] as const;
export type AnlassArt = (typeof ANLAESSE)[number];
export const ANLASS_TEXT: Readonly<Record<AnlassArt, { titel: string; erklaerung: string }>> = {
drift: {
titel: "Neue Abweichungen (Drift)",
erklaerung: "Nach dem Abgleich neu gefundene Befunde, je Host zusammengefasst.",
},
ablauf: {
titel: "Ablaufende Schlüssel",
erklaerung: "30, 14 und 7 Tage vor dem Ablauf – je Stufe genau eine Meldung.",
},
abgelaufen: {
titel: "Abgelaufene, noch ausgerollte Schlüssel",
erklaerung: "Abgelaufen, aber noch auf mindestens einem Host hinterlegt.",
},
unerreichbar: {
titel: "Host nicht erreichbar",
erklaerung: "Einmal je Ausfall, bis wieder ein Abgleich gelingt.",
},
hostkey: {
titel: "Geänderter Host-Key",
erklaerung: "Dringend: wird sofort gemeldet, nicht erst in der Zusammenfassung.",
},
};
/** Ablaufstufen in Tagen, absteigend. */
export const ABLAUF_STUFEN = [30, 14, 7] as const;
export type AblaufStufe = (typeof ABLAUF_STUFEN)[number];
/**
* Die Stufe, in der ein Schluessel gerade steht: die kleinste Stufe, deren Frist schon
* erreicht ist. Wer erst mit 10 Tagen Restlaufzeit angelegt wird, bekommt also die
* 14-Tage-Meldung und spaeter die 7-Tage-Meldung, aber nie nachtraeglich die 30-Tage-Meldung.
* Gezaehlt wird in Kalendertagen (Europe/Berlin) wie ueberall in der Oberflaeche.
*/
export function ablaufStufe(ablaufAm: Date, jetzt: Date = new Date()): AblaufStufe | null {
if (ablaufAm.getTime() <= jetzt.getTime()) return null;
const tage = tageBis(ablaufAm, jetzt);
for (const stufe of [...ABLAUF_STUFEN].reverse()) {
if (tage <= stufe) return stufe;
}
return null;
}
function tag(wert: Date): string {
return wert.toISOString().slice(0, 10);
}
/**
* Fingerabdruecke der Anlaesse – Schluessel des Versandprotokolls. Was hier gleich ist, wird
* nur einmal gemeldet. Beim Ablauf steht das Ablaufdatum mit drin: wird ein Schluessel
* verlaengert, beginnen die Stufen fuer das neue Datum von vorn.
*/
export const fingerabdruck = {
drift: (abweichungId: string) => `drift:${abweichungId}`,
hostkey: (abweichungId: string) => `hostkey:${abweichungId}`,
ablauf: (schluesselId: string, stufe: AblaufStufe, ablaufAm: Date) =>
`ablauf:${schluesselId}:${stufe}:${tag(ablaufAm)}`,
abgelaufen: (schluesselId: string, ablaufAm: Date) => `abgelaufen:${schluesselId}:${tag(ablaufAm)}`,
// `letzterAbgleichAm` aendert sich nur bei einem gelungenen Abgleich – so entsteht genau eine
// Meldung je Ausfall, und nach der Erholung kann der naechste Ausfall wieder gemeldet werden.
unerreichbar: (hostId: string, letzterAbgleichAm: Date | null) =>
`unerreichbar:${hostId}:${letzterAbgleichAm ? letzterAbgleichAm.toISOString() : "nie"}`,
test: (sendung: string) => `test:${sendung}`,
} as const;
// ---------------------------------------------------------------- Wiederholungen
/** Nach so vielen Fehlversuchen gibt sshkv eine Zustellung auf. */
export const MAX_VERSUCHE = 5;
/** 5, 10, 20, 40 Minuten – kurz genug fuer einen kurzen Ausfall des Mailservers. */
export function naechsterVersuch(versuche: number, jetzt: Date = new Date()): Date | null {
if (versuche >= MAX_VERSUCHE) return null;
return new Date(jetzt.getTime() + 5 * 60_000 * 2 ** (versuche - 1));
}
export type ProtokollStand = {
readonly ergebnis: "GESENDET" | "FEHLGESCHLAGEN";
readonly versuche: number;
readonly naechsterVersuchAm: Date | null;
};
/**
* Darf ein Anlass ueber einen Kanal (erneut) gemeldet werden?
* - noch nie versucht: ja, ausser es laeuft nur eine Wiederholungsrunde;
* - einmal erfolgreich: nie wieder;
* - fehlgeschlagen: wenn die Wartezeit um ist und noch Versuche uebrig sind.
*/
export function anlassFaellig(
stand: ProtokollStand | undefined,
jetzt: Date = new Date(),
nurWiederholungen = false,
): boolean {
if (!stand) return !nurWiederholungen;
if (stand.ergebnis === "GESENDET") return false;
if (stand.versuche >= MAX_VERSUCHE || !stand.naechsterVersuchAm) return false;
return stand.naechsterVersuchAm.getTime() <= jetzt.getTime();
}
// ---------------------------------------------------------------- Webhook-Ziel
/**
* Nur https, ausser `http` ist ausdruecklich erlaubt (internes Ziel). Liefert eine Meldung
* fuer das Formular oder null.
*/
export function webhookUrlPruefen(url: string, httpErlaubt: boolean): string | null {
let ziel: URL;
try {
ziel = new URL(url);
} catch {
return "Keine gültige Adresse.";
}
if (ziel.username || ziel.password) {
return "Zugangsdaten gehören nicht in die Adresse – bitte das Geheimnis-Feld nutzen.";
}
if (ziel.protocol === "https:") return null;
if (ziel.protocol === "http:") {
return httpErlaubt ? null : "Nur https:// – http:// nur mit dem Häkchen für interne Ziele.";
}
return "Nur https:// (oder ausdrücklich erlaubtes http://).";
}
/** Fuer Protokoll und Versandliste: nur Schema und Host, nie Pfad oder Query (Tokens). */
export function webhookZielKurz(url: string): string {
try {
const ziel = new URL(url);
return `${ziel.protocol}//${ziel.host}`;
} catch {
return "(ungültige Adresse)";
}
}
// ---------------------------------------------------------------- Zeitplaene
/**
* Lesbare Fassung der gaengigen cron-Formen fuer die Einstellungsseite; bei allem anderen
* null – dann zeigt die Seite nur den Ausdruck.
*/
export function cronBeschreiben(ausdruck: string): string | null {
const teile = ausdruck.trim().split(/\s+/);
if (teile.length !== 5) return null;
const [minute, stunde, tagImMonat, monat, wochentag] = teile as [string, string, string, string, string];
const zahl = (wert: string, max: number) => /^\d{1,2}$/.test(wert) && Number(wert) <= max;
const intervall = /^\*\/(\d{1,2})$/.exec(minute);
if (intervall && stunde === "*" && tagImMonat === "*" && monat === "*" && wochentag === "*") {
return `alle ${Number(intervall[1])} Minuten`;
}
if (!zahl(minute, 59) || !zahl(stunde, 23) || tagImMonat !== "*" || monat !== "*") return null;
const uhr = `${stunde.padStart(2, "0")}:${minute.padStart(2, "0")} Uhr`;
if (wochentag === "*") return `täglich um ${uhr}`;
if (wochentag === "1-5") return `werktags um ${uhr}`;
return null;
}