import { createHmac } from "node:crypto"; import { datum, datumZeit } from "@/lib/format"; import { PRODUKTNAME } from "@/lib/version"; /** * Nachrichtenaufbau der Benachrichtigungen (#028): Betreff, Text- und HTML-Mail auf Deutsch * und die JSON-Nutzlast des Webhooks – aus denselben Daten, damit beide Wege dasselbe sagen. * * Alles, was in HTML landet, stammt teils von den Hosts (Kommentare in authorized_keys, * Benutzernamen) und wird deshalb maskiert. Schlichtes HTML mit Inline-Stilen, weil * E-Mail-Programme Stylesheets ignorieren (Muster aus ProxLedger). */ export const ART_TEXT: Readonly> = { UNBEKANNTER_SCHLUESSEL: "Unbekannter Schlüssel", FEHLENDER_SCHLUESSEL: "Schlüssel fehlt", ZUSAETZLICH_IM_BLOCK: "Block von Hand geändert", DATEI_GEAENDERT: "Block beschädigt", RECHTE: "Rechte", HOSTKEY_GEAENDERT: "Host-Key geändert", PRIVATE_KEY_FEHLT: "Private Key fehlt/verändert", }; export type Befund = { readonly art: string; readonly zielbenutzer: string | null; readonly fingerprint: string | null; readonly text: string | null; }; export type HostDrift = { readonly hostId: string; readonly name: string; readonly adresse: string; readonly befunde: readonly Befund[]; }; export type HostUnerreichbar = { readonly hostId: string; readonly name: string; readonly adresse: string; readonly zuletztGeprueftAm: Date | null; readonly letzterAbgleichAm: Date | null; }; export type SchluesselAblauf = { readonly schluesselId: string; readonly name: string; readonly fingerprint: string; readonly ablaufAm: Date; readonly tage: number; readonly stufe: number; }; export type SchluesselAbgelaufen = { readonly schluesselId: string; readonly name: string; readonly fingerprint: string; readonly ablaufAm: Date; readonly hosts: readonly string[]; }; export type Zusammenfassung = { readonly drift: readonly HostDrift[]; readonly unerreichbar: readonly HostUnerreichbar[]; readonly ablauf: readonly SchluesselAblauf[]; readonly abgelaufen: readonly SchluesselAbgelaufen[]; }; export type HostKeyAenderung = { readonly hostId: string; readonly name: string; readonly adresse: string; readonly befunde: readonly { algorithmus: string; erwartet: string | null; gesehen: string }[]; }; export type WebhookNutzlast = { readonly ereignis: "zusammenfassung" | "hostkey.geaendert" | "test"; readonly zeitstempel: string; readonly instanz: string; readonly [weiteres: string]: unknown; }; export type Nachricht = { readonly betreff: string; readonly text: string; readonly html: string; readonly nutzlast: WebhookNutzlast; }; export type Rahmen = { readonly appUrl: string; readonly jetzt: Date }; // ---------------------------------------------------------------- Hilfen export function html(text: string): string { return text .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function basis(appUrl: string): string { return appUrl.replace(/\/+$/, ""); } function mehrzahl(anzahl: number, eins: string, viele: string): string { return `${anzahl} ${anzahl === 1 ? eins : viele}`; } function htmlRahmen(inhalt: string, appUrl: string, linkText: string, pfad: string): string { return `
${PRODUKTNAME} · Benachrichtigung
${inhalt}

${html(linkText)}

`; } function befundZeile(b: Befund): string { const teile = [ART_TEXT[b.art] ?? b.art]; if (b.zielbenutzer) teile.push(`Benutzer ${b.zielbenutzer}`); if (b.fingerprint) teile.push(b.fingerprint); if (b.text) teile.push(b.text); return teile.join(" – "); } function befundJson(b: Befund) { return { art: b.art, artText: ART_TEXT[b.art] ?? b.art, zielbenutzer: b.zielbenutzer, fingerprint: b.fingerprint, text: b.text, }; } const H3 = 'style="font-size:16px;margin:20px 0 6px"'; const UL = 'style="margin:4px 0 12px;padding-left:20px"'; const MONO = "font-family:'JetBrains Mono',Consolas,monospace;font-size:13px"; // ---------------------------------------------------------------- Zusammenfassung export function zusammenfassungLeer(z: Zusammenfassung): boolean { return z.drift.length + z.unerreichbar.length + z.ablauf.length + z.abgelaufen.length === 0; } /** Tägliche Zusammenfassung der Anlaesse (a)–(d). */ export function zusammenfassungNachricht(z: Zusammenfassung, rahmen: Rahmen): Nachricht { const befundAnzahl = z.drift.reduce((summe, h) => summe + h.befunde.length, 0); const kopf: string[] = []; if (befundAnzahl > 0) kopf.push(mehrzahl(befundAnzahl, "neue Abweichung", "neue Abweichungen")); if (z.unerreichbar.length > 0) kopf.push(mehrzahl(z.unerreichbar.length, "Host nicht erreichbar", "Hosts nicht erreichbar")); if (z.ablauf.length > 0) kopf.push(mehrzahl(z.ablauf.length, "Schlüssel läuft ab", "Schlüssel laufen ab")); if (z.abgelaufen.length > 0) kopf.push( mehrzahl(z.abgelaufen.length, "abgelaufener Schlüssel ausgerollt", "abgelaufene Schlüssel ausgerollt"), ); const betreff = `[sshkv] Zusammenfassung: ${kopf.join(", ") || "keine neuen Meldungen"}`; const url = basis(rahmen.appUrl); const text: string[] = [`${PRODUKTNAME} – Zusammenfassung vom ${datumZeit(rahmen.jetzt)}`, ""]; const teile: string[] = [ `

Zusammenfassung vom ${html(datumZeit(rahmen.jetzt))}

`, ]; if (z.drift.length > 0) { text.push("Neue Abweichungen (Drift):"); teile.push(`

Neue Abweichungen (Drift)

`); for (const host of z.drift) { text.push(` ${host.name} (${host.adresse}):`); for (const b of host.befunde) text.push(` · ${befundZeile(b)}`); text.push(` ${url}/hosts/${host.hostId}`); teile.push( `

${html(host.name)} ${html(host.adresse)}

`, ``, ); } text.push(""); } if (z.unerreichbar.length > 0) { text.push("Hosts nicht erreichbar:"); teile.push(`

Hosts nicht erreichbar

"); text.push(""); } if (z.ablauf.length > 0) { text.push("Bald ablaufende Schlüssel:"); teile.push(`

Bald ablaufende Schlüssel

"); text.push(""); } if (z.abgelaufen.length > 0) { text.push("Abgelaufen, aber noch ausgerollt:"); teile.push(`

Abgelaufen, aber noch ausgerollt

"); text.push(""); } text.push(`Übersicht: ${url}/`); return { betreff, text: text.join("\n"), html: htmlRahmen(teile.join("\n"), rahmen.appUrl, "Übersicht öffnen", "/"), nutzlast: { ereignis: "zusammenfassung", zeitstempel: rahmen.jetzt.toISOString(), instanz: url, drift: z.drift.map((h) => ({ host: { id: h.hostId, name: h.name, adresse: h.adresse }, befunde: h.befunde.map(befundJson), })), unerreichbar: z.unerreichbar.map((h) => ({ host: { id: h.hostId, name: h.name, adresse: h.adresse }, zuletztGeprueftAm: h.zuletztGeprueftAm?.toISOString() ?? null, letzterAbgleichAm: h.letzterAbgleichAm?.toISOString() ?? null, })), ablauf: z.ablauf.map((s) => ({ schluessel: { id: s.schluesselId, name: s.name, fingerprint: s.fingerprint }, ablaufAm: s.ablaufAm.toISOString(), tage: s.tage, stufe: s.stufe, })), abgelaufen: z.abgelaufen.map((s) => ({ schluessel: { id: s.schluesselId, name: s.name, fingerprint: s.fingerprint }, ablaufAm: s.ablaufAm.toISOString(), hosts: [...s.hosts], })), }, }; } // ---------------------------------------------------------------- Host-Key (sofort) /** Dringende Einzelmeldung (e) je Host. */ export function hostKeyNachricht(h: HostKeyAenderung, rahmen: Rahmen): Nachricht { const url = `${basis(rahmen.appUrl)}/hosts/${h.hostId}`; const betreff = `[sshkv] DRINGEND: Host-Key von ${h.name} hat sich geändert`; const zeilen = h.befunde.map( (b) => `${b.algorithmus}: erwartet ${b.erwartet ?? "(unbekannt)"}, gesehen ${b.gesehen}`, ); const text = [ `Der Host-Key von ${h.name} (${h.adresse}) hat sich geändert.`, "", ...zeilen.map((z) => ` · ${z}`), "", "sshkv verbindet sich mit diesem Host nicht mehr, bis jemand den neuen Key geprüft und", "bestätigt hat. Ist die Änderung nicht erklärbar (Neuinstallation, Austausch), kann ein", "Angreifer zwischen sshkv und dem Host sitzen.", "", url, ].join("\n"); const inhalt = `

Dringend

Host-Key von ${html(h.name)} geändert

${html(h.adresse)}

sshkv verbindet sich mit diesem Host nicht mehr, bis jemand den neuen Key geprüft und bestätigt hat. Ist die Änderung nicht erklärbar (Neuinstallation, Austausch), kann ein Angreifer zwischen sshkv und dem Host sitzen.

`; return { betreff, text, html: htmlRahmen(inhalt, rahmen.appUrl, "Host öffnen und Key prüfen", `/hosts/${h.hostId}`), nutzlast: { ereignis: "hostkey.geaendert", dringend: true, zeitstempel: rahmen.jetzt.toISOString(), instanz: basis(rahmen.appUrl), host: { id: h.hostId, name: h.name, adresse: h.adresse }, befunde: h.befunde.map((b) => ({ art: "HOSTKEY_GEAENDERT", artText: ART_TEXT["HOSTKEY_GEAENDERT"], algorithmus: b.algorithmus, erwartet: b.erwartet, fingerprint: b.gesehen, })), }, }; } // ---------------------------------------------------------------- Test export function testNachricht(kanal: "EMAIL" | "WEBHOOK", rahmen: Rahmen): Nachricht { const weg = kanal === "EMAIL" ? "E-Mail" : "Webhook"; const satz = `Diese Testnachricht bestätigt, dass der Versand per ${weg} funktioniert.`; return { betreff: "[sshkv] Testnachricht", text: `${satz}\nGesendet am ${datumZeit(rahmen.jetzt)}.\n\n${basis(rahmen.appUrl)}/einstellungen`, html: htmlRahmen( `

Testnachricht

${html(satz)}

Gesendet am ${html(datumZeit(rahmen.jetzt))}.

`, rahmen.appUrl, "Einstellungen öffnen", "/einstellungen", ), nutzlast: { ereignis: "test", zeitstempel: rahmen.jetzt.toISOString(), instanz: basis(rahmen.appUrl), text: satz, }, }; } // ---------------------------------------------------------------- Webhook /** Der Body wird genau einmal serialisiert – signiert wird, was auch gesendet wird. */ export function webhookKoerper(nachricht: Nachricht): string { return JSON.stringify({ betreff: nachricht.betreff, ...nachricht.nutzlast }); } /** `X-Sshkv-Signatur: sha256=` – HMAC-SHA256 ueber den rohen Body. */ export function signatur(koerper: string, geheimnis: string): string { return `sha256=${createHmac("sha256", geheimnis).update(koerper, "utf8").digest("hex")}`; }