Files
sshkeyvault/src/server/queries/einstellungen.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

103 lines
3.6 KiB
TypeScript

import { alsAnsicht, type BenachrichtigungAnsicht } from "@/lib/benachrichtigung/einstellungen";
import { cronBeschreiben, webhookZielKurz } from "@/lib/benachrichtigung/regeln";
import { prisma } from "@/lib/db";
import { VERSION } from "@/lib/version";
import { oidcAktiv, oidcName } from "@/server/auth/oidc";
import { benachrichtigungLesen } from "@/server/services/benachrichtigung";
/**
* Lesemodell der Einstellungsseite (#028). Lesen duerfen alle Rollen; Geheimnisse verlassen
* diese Datei nur als „gesetzt ja/nein“ (`alsAnsicht`).
*/
export type ZustellungZeile = {
readonly sendung: string;
readonly zeitpunkt: Date;
readonly kanal: "EMAIL" | "WEBHOOK";
readonly betreff: string;
readonly anlaesse: number;
readonly ergebnis: "GESENDET" | "FEHLGESCHLAGEN";
readonly versuche: number;
readonly fehler: string | null;
readonly naechsterVersuchAm: Date | null;
};
export type Zeitplan = { readonly ausdruck: string; readonly beschreibung: string | null };
export type EinstellungenSeite = {
readonly benachrichtigung: BenachrichtigungAnsicht;
readonly webhookZiel: string | null;
readonly zustellungen: readonly ZustellungZeile[];
readonly abgleich: Zeitplan;
readonly meldung: Zeitplan;
readonly instanz: {
readonly version: string;
readonly deploymentKey: { readonly name: string; readonly fingerprint: string } | null;
readonly oidc: { readonly name: string; readonly konfiguriert: boolean };
readonly notfall: { readonly vorhanden: boolean; readonly totpEingerichtet: boolean };
};
};
function zeitplan(variable: string, standard: string): Zeitplan {
const ausdruck = process.env[variable]?.trim() || standard;
return { ausdruck, beschreibung: cronBeschreiben(ausdruck) };
}
/** Je Nachricht eine Zeile: alle Anlaesse einer Sendung teilen Kennung, Betreff und Ergebnis. */
export async function letzteZustellungen(anzahl = 12): Promise<ZustellungZeile[]> {
const zeilen = await prisma.zustellung.findMany({
orderBy: { zeitpunkt: "desc" },
take: 500,
select: {
sendung: true,
zeitpunkt: true,
kanal: true,
betreff: true,
ergebnis: true,
versuche: true,
fehler: true,
naechsterVersuchAm: true,
},
});
const gruppen = new Map<string, ZustellungZeile>();
for (const z of zeilen) {
const vorhanden = gruppen.get(z.sendung);
if (vorhanden) {
gruppen.set(z.sendung, {
...vorhanden,
anlaesse: vorhanden.anlaesse + 1,
versuche: Math.max(vorhanden.versuche, z.versuche),
});
} else {
if (gruppen.size >= anzahl) continue;
gruppen.set(z.sendung, { ...z, anlaesse: 1 });
}
}
return [...gruppen.values()];
}
export async function einstellungenSeite(): Promise<EinstellungenSeite> {
const [gespeichert, zustellungen, deploymentKey, notfall] = await Promise.all([
benachrichtigungLesen(),
letzteZustellungen(),
prisma.schluessel.findFirst({
where: { instanzStandard: true },
select: { name: true, fingerprint: true },
}),
prisma.benutzer.findFirst({ where: { istNotfall: true }, select: { totpEingerichtetAm: true } }),
]);
return {
benachrichtigung: alsAnsicht(gespeichert),
webhookZiel: gespeichert.webhook.url ? webhookZielKurz(gespeichert.webhook.url) : null,
zustellungen,
abgleich: zeitplan("SSHKV_SYNC_CRON", "30 2 * * *"),
meldung: zeitplan("SSHKV_MELDUNG_CRON", "0 7 * * *"),
instanz: {
version: VERSION,
deploymentKey,
oidc: { name: oidcName(), konfiguriert: oidcAktiv() },
notfall: { vorhanden: notfall !== null, totpEingerichtet: Boolean(notfall?.totpEingerichtetAm) },
},
};
}