Files
sshkeyvault/src/lib/benachrichtigung/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

142 lines
5.4 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 { z } from "@/lib/validierung";
import { ANLAESSE, webhookUrlPruefen, type AnlassArt } from "./regeln";
/**
* Form der Benachrichtigungs-Einstellungen (#028). Gespeichert als JSON in `Einstellung`
* unter EINSTELLUNG_BENACHRICHTIGUNG; Passwort und Webhook-Geheimnis liegen dort nur
* verschluesselt (`verschluesseln()` mit SSHKV_APP_SECRET).
*/
export const EINSTELLUNG_BENACHRICHTIGUNG = "benachrichtigungen";
/** STARTTLS ist der Weg von Microsoft 365 (Port 587); KEINE nur fuer interne Relays. */
export const SMTP_SICHERHEIT = ["STARTTLS", "TLS", "KEINE"] as const;
export type SmtpSicherheit = (typeof SMTP_SICHERHEIT)[number];
export type GespeicherteBenachrichtigung = {
email: {
aktiv: boolean;
server: string;
port: number;
sicherheit: SmtpSicherheit;
absender: string;
benutzer: string;
/** Chiffrat aus `verschluesseln()` oder null. */
passwort: string | null;
empfaenger: string[];
};
webhook: {
aktiv: boolean;
url: string;
httpErlaubt: boolean;
/** Chiffrat aus `verschluesseln()` oder null. */
geheimnis: string | null;
};
anlaesse: Record<AnlassArt, boolean>;
};
/** Vorbelegung fuer Microsoft 365 – alles aus, bis ein Admin es einschaltet. */
export const STANDARD_BENACHRICHTIGUNG: GespeicherteBenachrichtigung = {
email: {
aktiv: false,
server: "smtp.office365.com",
port: 587,
sicherheit: "STARTTLS",
absender: "",
benutzer: "",
passwort: null,
empfaenger: [],
},
webhook: { aktiv: false, url: "", httpErlaubt: false, geheimnis: null },
anlaesse: { drift: true, ablauf: true, abgelaufen: true, unerreichbar: true, hostkey: true },
};
/**
* Was die Oberflaeche sehen darf: statt der Geheimnisse nur, ob sie gesetzt sind. Auch das
* Chiffrat geht nicht in Client-Props – es waere mit SSHKV_APP_SECRET lesbar.
*/
export type BenachrichtigungAnsicht = {
email: Omit<GespeicherteBenachrichtigung["email"], "passwort"> & { passwortGesetzt: boolean };
webhook: Omit<GespeicherteBenachrichtigung["webhook"], "geheimnis"> & { geheimnisGesetzt: boolean };
anlaesse: Record<AnlassArt, boolean>;
};
export function alsAnsicht(gespeichert: GespeicherteBenachrichtigung): BenachrichtigungAnsicht {
const { passwort, ...email } = gespeichert.email;
const { geheimnis, ...webhook } = gespeichert.webhook;
return {
email: { ...email, passwortGesetzt: passwort !== null },
webhook: { ...webhook, geheimnisGesetzt: geheimnis !== null },
anlaesse: { ...gespeichert.anlaesse },
};
}
/** Liest gespeichertes JSON tolerant: fehlende Felder bekommen die Vorbelegung. */
export function gespeichertLesen(wert: unknown): GespeicherteBenachrichtigung {
const roh = (
typeof wert === "object" && wert !== null ? wert : {}
) as Partial<GespeicherteBenachrichtigung>;
const s = STANDARD_BENACHRICHTIGUNG;
return {
email: { ...s.email, ...(roh.email ?? {}) },
webhook: { ...s.webhook, ...(roh.webhook ?? {}) },
anlaesse: { ...s.anlaesse, ...(roh.anlaesse ?? {}) },
};
}
// ---------------------------------------------------------------- Formular
/** Empfaengerliste: Komma, Semikolon, Leerzeichen oder Zeilenumbruch trennen. */
export function empfaengerAus(text: string): string[] {
return [
...new Set(
text
.split(/[\s,;]+/)
.map((e) => e.trim())
.filter(Boolean),
),
];
}
const email = z.email({ error: "Keine gültige E-Mail-Adresse." });
export const benachrichtigungSchema = z
.object({
emailAktiv: z.boolean(),
emailServer: z.string().trim().max(253),
emailPort: z.coerce.number().int().min(1, "Port 1–65535.").max(65535, "Port 1–65535."),
emailSicherheit: z.enum(SMTP_SICHERHEIT),
emailAbsender: z.string().trim().max(254),
emailBenutzer: z.string().trim().max(254),
/** Leer = unveraendert lassen. */
emailPasswort: z.string().max(1024).optional(),
emailPasswortEntfernen: z.boolean(),
emailEmpfaenger: z.array(z.string()).max(50, "Höchstens 50 Empfänger."),
webhookAktiv: z.boolean(),
webhookUrl: z.string().trim().max(2048),
webhookHttpErlaubt: z.boolean(),
webhookGeheimnis: z.string().max(1024).optional(),
webhookGeheimnisEntfernen: z.boolean(),
anlaesse: z.array(z.enum(ANLAESSE)),
})
.superRefine((e, ctx) => {
const fehler = (pfad: string, message: string) => ctx.addIssue({ code: "custom", path: [pfad], message });
if (e.emailAktiv && !e.emailServer) fehler("emailServer", "Server angeben.");
if (e.emailServer && !/^[A-Za-z0-9.-]+$/.test(e.emailServer))
fehler("emailServer", "Nur Hostname oder IP.");
if (e.emailAktiv && !e.emailAbsender) fehler("emailAbsender", "Absender angeben.");
if (e.emailAbsender && !email.safeParse(e.emailAbsender).success)
fehler("emailAbsender", "Keine gültige E-Mail-Adresse.");
if (e.emailAktiv && e.emailEmpfaenger.length === 0)
fehler("emailEmpfaenger", "Mindestens ein Empfänger.");
const falsch = e.emailEmpfaenger.find((a) => !email.safeParse(a).success);
if (falsch) fehler("emailEmpfaenger", `„${falsch.slice(0, 80)}“ ist keine gültige E-Mail-Adresse.`);
if (e.webhookAktiv && !e.webhookUrl) fehler("webhookUrl", "Adresse angeben.");
if (e.webhookUrl) {
const meldung = webhookUrlPruefen(e.webhookUrl, e.webhookHttpErlaubt);
if (meldung) fehler("webhookUrl", meldung);
}
});
export type BenachrichtigungEingabe = z.input<typeof benachrichtigungSchema>;
export type BenachrichtigungDaten = z.output<typeof benachrichtigungSchema>;