Files
sshkeyvault/src/lib/benachrichtigung/regeln.ts
T
florian.wildeandClaude Opus 5.5 8f5b31c0cb 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>
2026-10-12 16:54:43 +02:00

164 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}