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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user