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