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
+182
View File
@@ -0,0 +1,182 @@
import type { Posten } from "@/lib/benachrichtigung/posten";
import { ablaufStufe, fingerabdruck, type AnlassArt } from "@/lib/benachrichtigung/regeln";
import { prisma } from "@/lib/db";
import { tageBis } from "@/lib/format";
/**
* Sammelt die meldbaren Anlaesse aus der Datenbank (#028). Der Abgleich selbst meldet nichts:
* er schreibt Abweichungen und Erreichbarkeit, und diese Datei liest sie. So bleibt der
* Abgleich-Ablauf unberuehrt, und ein verpasster Versand holt sich der naechste Lauf.
*/
const AUSGEROLLT = ["AUSGEROLLT", "ABWEICHUNG"] as const;
async function drift(): Promise<Posten[]> {
const offen = await prisma.abweichung.findMany({
where: { behobenAm: null, art: { not: "HOSTKEY_GEAENDERT" }, host: { archiviertAm: null } },
select: {
id: true,
art: true,
zielbenutzer: true,
fingerprint: true,
details: true,
host: { select: { id: true, name: true, adresse: true } },
},
orderBy: { eroeffnetAm: "asc" },
});
return offen.map((a) => ({
art: "drift",
anlass: fingerabdruck.drift(a.id),
host: { hostId: a.host.id, name: a.host.name, adresse: a.host.adresse },
befund: {
art: a.art,
zielbenutzer: a.zielbenutzer,
fingerprint: a.fingerprint,
text: ((a.details ?? {}) as { text?: unknown }).text?.toString() ?? null,
},
}));
}
async function hostKey(): Promise<Posten[]> {
const offen = await prisma.abweichung.findMany({
where: { behobenAm: null, art: "HOSTKEY_GEAENDERT", host: { archiviertAm: null } },
select: {
id: true,
fingerprint: true,
details: true,
host: { select: { id: true, name: true, adresse: true } },
},
orderBy: { eroeffnetAm: "asc" },
});
return offen.map((a) => {
const details = (a.details ?? {}) as { erwartet?: unknown; algorithmus?: unknown };
return {
art: "hostkey",
anlass: fingerabdruck.hostkey(a.id),
host: { hostId: a.host.id, name: a.host.name, adresse: a.host.adresse },
befund: {
algorithmus: typeof details.algorithmus === "string" ? details.algorithmus : "unbekannt",
erwartet: typeof details.erwartet === "string" ? details.erwartet : null,
gesehen: a.fingerprint ?? "unbekannt",
},
};
});
}
async function unerreichbar(): Promise<Posten[]> {
const hosts = await prisma.host.findMany({
where: {
archiviertAm: null,
bootstrapAm: { not: null },
erreichbar: false,
// Ein geaenderter Host-Key laesst den Abgleich ebenfalls scheitern; dafuer gibt es die
// dringende Meldung (e) – hier waere sie nur ein irrefuehrendes Echo.
abweichungen: { none: { art: "HOSTKEY_GEAENDERT", behobenAm: null } },
},
select: { id: true, name: true, adresse: true, zuletztGeprueftAm: true, letzterAbgleichAm: true },
});
return hosts.map((h) => ({
art: "unerreichbar",
anlass: fingerabdruck.unerreichbar(h.id, h.letzterAbgleichAm),
host: {
hostId: h.id,
name: h.name,
adresse: h.adresse,
zuletztGeprueftAm: h.zuletztGeprueftAm,
letzterAbgleichAm: h.letzterAbgleichAm,
},
}));
}
async function ablauf(jetzt: Date): Promise<Posten[]> {
const bis = new Date(jetzt.getTime() + 31 * 86_400_000);
// Feste select-Liste: Private-Key-Felder kommen hier nie vorbei (CLAUDE.md).
const schluessel = await prisma.schluessel.findMany({
where: { status: "AKTIV", widerrufEingeleitetAm: null, ablaufAm: { gt: jetzt, lte: bis } },
select: { id: true, name: true, fingerprint: true, ablaufAm: true },
});
const posten: Posten[] = [];
for (const s of schluessel) {
if (!s.ablaufAm) continue;
const stufe = ablaufStufe(s.ablaufAm, jetzt);
if (stufe === null) continue;
posten.push({
art: "ablauf",
anlass: fingerabdruck.ablauf(s.id, stufe, s.ablaufAm),
schluessel: {
schluesselId: s.id,
name: s.name,
fingerprint: s.fingerprint,
ablaufAm: s.ablaufAm,
tage: tageBis(s.ablaufAm, jetzt),
stufe,
},
});
}
return posten;
}
async function abgelaufen(jetzt: Date): Promise<Posten[]> {
const schluessel = await prisma.schluessel.findMany({
where: {
status: { in: ["AKTIV", "ABGELAUFEN"] },
ablaufAm: { lte: jetzt },
OR: [
{ publicVerwendungen: { some: { zustand: { in: [...AUSGEROLLT] } } } },
{ privateVerwendungen: { some: { zustand: { in: [...AUSGEROLLT] } } } },
],
},
select: {
id: true,
name: true,
fingerprint: true,
ablaufAm: true,
publicVerwendungen: {
where: { zustand: { in: [...AUSGEROLLT] } },
select: { host: { select: { name: true } }, hostgruppe: { select: { name: true } } },
},
privateVerwendungen: {
where: { zustand: { in: [...AUSGEROLLT] } },
select: { host: { select: { name: true } } },
},
},
});
return schluessel.flatMap((s): Posten[] => {
if (!s.ablaufAm) return [];
const hosts = new Set<string>();
for (const v of s.publicVerwendungen) {
if (v.host) hosts.add(v.host.name);
else if (v.hostgruppe) hosts.add(`Gruppe ${v.hostgruppe.name}`);
}
for (const v of s.privateVerwendungen) hosts.add(v.host.name);
return [
{
art: "abgelaufen",
anlass: fingerabdruck.abgelaufen(s.id, s.ablaufAm),
schluessel: {
schluesselId: s.id,
name: s.name,
fingerprint: s.fingerprint,
ablaufAm: s.ablaufAm,
hosts: [...hosts].sort((a, b) => a.localeCompare(b, "de")),
},
},
];
});
}
/** Alle Posten der eingeschalteten Anlaesse. */
export async function postenSammeln(
arten: readonly AnlassArt[],
jetzt: Date = new Date(),
): Promise<Posten[]> {
const sammler: Record<AnlassArt, () => Promise<Posten[]>> = {
drift,
hostkey: hostKey,
unerreichbar,
ablauf: () => ablauf(jetzt),
abgelaufen: () => abgelaufen(jetzt),
};
const teile = await Promise.all(arten.map((art) => sammler[art]()));
return teile.flat();
}