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>
164 lines
6.5 KiB
TypeScript
164 lines
6.5 KiB
TypeScript
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;
|
||
}
|