Files
sshkeyvault/src/worker/benachrichtigung/anlaesse.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

183 lines
5.9 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 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();
}