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>
201 lines
8.2 KiB
TypeScript
201 lines
8.2 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
||
import { createServer, type IncomingHttpHeaders, type Server } from "node:http";
|
||
import type { AddressInfo } from "node:net";
|
||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||
import { signatur } from "@/lib/benachrichtigung/nachrichten";
|
||
import { prisma } from "@/lib/db";
|
||
import { benachrichtigungSpeichern } from "@/server/services/benachrichtigung";
|
||
import { taktVersenden, testVersenden, zusammenfassungVersenden } from "@/worker/benachrichtigung/versand";
|
||
import { datenbankLeeren } from "../hilfen/db";
|
||
|
||
/**
|
||
* Benachrichtigungen gegen echte Gegenstellen (#028): Mailpit fuer SMTP und ein lokaler
|
||
* HTTP-Empfaenger fuer den Webhook. Mailpit muss laufen, z. B.
|
||
* docker run -d --name sshkv-mailpit -e MP_SMTP_DISABLE_RDNS=true \
|
||
* -p 127.0.0.1:1026:1025 -p 127.0.0.1:8026:8025 axllent/mailpit
|
||
* (ohne MP_SMTP_DISABLE_RDNS wartet Mailpit je Verbindung fuenf Sekunden auf Reverse-DNS).
|
||
* Adressen ueber SSHKV_TEST_SMTP (host:port) und SSHKV_TEST_MAILPIT_API.
|
||
*/
|
||
const SMTP = process.env["SSHKV_TEST_SMTP"]?.trim() || "127.0.0.1:1026";
|
||
const MAILPIT = (process.env["SSHKV_TEST_MAILPIT_API"]?.trim() || "http://127.0.0.1:8026").replace(
|
||
/\/+$/,
|
||
"",
|
||
);
|
||
const [SMTP_HOST, SMTP_PORT] = [SMTP.slice(0, SMTP.lastIndexOf(":")), SMTP.slice(SMTP.lastIndexOf(":") + 1)];
|
||
|
||
const AKTEUR = { id: "admin", name: "Ada Admin" };
|
||
const GEHEIMNIS = "integration-webhook-geheimnis";
|
||
|
||
type Empfangen = { kopf: IncomingHttpHeaders; koerper: string };
|
||
let empfaenger: Server;
|
||
let empfangen: Empfangen[] = [];
|
||
let antwortStatus = 204;
|
||
let webhookUrl = "";
|
||
|
||
beforeAll(async () => {
|
||
const antwort = await fetch(`${MAILPIT}/api/v1/info`).catch(() => null);
|
||
if (!antwort?.ok) throw new Error(`Mailpit unter ${MAILPIT} nicht erreichbar – siehe Kopf dieser Datei.`);
|
||
empfaenger = createServer((anfrage, antwort) => {
|
||
const teile: Buffer[] = [];
|
||
anfrage.on("data", (teil: Buffer) => teile.push(teil));
|
||
anfrage.on("end", () => {
|
||
empfangen.push({ kopf: anfrage.headers, koerper: Buffer.concat(teile).toString("utf8") });
|
||
antwort.writeHead(antwortStatus).end();
|
||
});
|
||
});
|
||
await new Promise<void>((fertig) => empfaenger.listen(0, "127.0.0.1", fertig));
|
||
webhookUrl = `http://127.0.0.1:${(empfaenger.address() as AddressInfo).port}/hook/sshkv`;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await new Promise((fertig) => empfaenger.close(fertig));
|
||
});
|
||
|
||
let postfach = "";
|
||
beforeEach(async () => {
|
||
await datenbankLeeren();
|
||
empfangen = [];
|
||
antwortStatus = 204;
|
||
// Eigene Adresse je Test: Mailpit wird nicht geleert, gesucht wird nach dem Empfaenger.
|
||
postfach = `lauf-${randomUUID().slice(0, 8)}@sshkv.test`;
|
||
});
|
||
|
||
async function einstellen(sicherheit: "KEINE" | "STARTTLS" = "KEINE") {
|
||
await benachrichtigungSpeichern(
|
||
{
|
||
emailAktiv: true,
|
||
emailServer: SMTP_HOST,
|
||
emailPort: SMTP_PORT,
|
||
emailSicherheit: sicherheit,
|
||
emailAbsender: "sshkv@sshkv.test",
|
||
emailBenutzer: "",
|
||
emailPasswortEntfernen: false,
|
||
emailEmpfaenger: [postfach],
|
||
webhookAktiv: true,
|
||
webhookUrl,
|
||
webhookHttpErlaubt: true,
|
||
webhookGeheimnis: GEHEIMNIS,
|
||
webhookGeheimnisEntfernen: false,
|
||
anlaesse: ["drift", "ablauf", "abgelaufen", "unerreichbar", "hostkey"],
|
||
},
|
||
AKTEUR,
|
||
);
|
||
}
|
||
|
||
type MailpitTreffer = { ID: string; Subject: string; To: { Address: string }[] };
|
||
|
||
async function postfachLesen(): Promise<MailpitTreffer[]> {
|
||
// Mailpit nimmt die Nachricht asynchron an – kurz warten, bis sie suchbar ist.
|
||
for (let versuch = 0; versuch < 20; versuch += 1) {
|
||
const antwort = await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:${postfach}`)}`);
|
||
const daten = (await antwort.json()) as { messages: MailpitTreffer[] };
|
||
if (daten.messages.length > 0) return daten.messages;
|
||
await new Promise((weiter) => setTimeout(weiter, 150));
|
||
}
|
||
return [];
|
||
}
|
||
|
||
async function mailLesen(id: string): Promise<{ Text: string; HTML: string; Subject: string }> {
|
||
const antwort = await fetch(`${MAILPIT}/api/v1/message/${id}`);
|
||
return (await antwort.json()) as { Text: string; HTML: string; Subject: string };
|
||
}
|
||
|
||
describe("E-Mail über Mailpit", () => {
|
||
it("Testnachricht kommt an", async () => {
|
||
await einstellen();
|
||
expect(await testVersenden("EMAIL", AKTEUR)).toBeNull();
|
||
const treffer = await postfachLesen();
|
||
expect(treffer).toHaveLength(1);
|
||
expect(treffer[0]?.Subject).toBe("[sshkv] Testnachricht");
|
||
const mail = await mailLesen(treffer[0]!.ID);
|
||
expect(mail.Text).toContain("Versand per E-Mail funktioniert");
|
||
expect(mail.HTML).toContain("Testnachricht");
|
||
});
|
||
|
||
it("Zusammenfassung mit Drift je Host, Text und HTML", async () => {
|
||
await einstellen();
|
||
const host = await prisma.host.create({
|
||
data: { name: "srv-mail", adresse: "10.1.1.1", bootstrapAm: new Date(), erreichbar: true },
|
||
});
|
||
await prisma.abweichung.create({
|
||
data: {
|
||
hostId: host.id,
|
||
art: "UNBEKANNTER_SCHLUESSEL",
|
||
zielbenutzer: "root",
|
||
fingerprint: "SHA256:fremd",
|
||
details: { text: "Kommentar <b>fett</b>" },
|
||
},
|
||
});
|
||
expect(await zusammenfassungVersenden()).toEqual({ gesendet: 2, fehlgeschlagen: 0 });
|
||
const treffer = await postfachLesen();
|
||
expect(treffer[0]?.Subject).toBe("[sshkv] Zusammenfassung: 1 neue Abweichung");
|
||
const mail = await mailLesen(treffer[0]!.ID);
|
||
expect(mail.Text).toContain("srv-mail (10.1.1.1):");
|
||
expect(mail.Text).toContain("Unbekannter Schlüssel – Benutzer root – SHA256:fremd");
|
||
expect(mail.HTML).toContain("Kommentar <b>fett</b>");
|
||
|
||
// Zweiter Lauf: nichts Neues, keine zweite Mail.
|
||
expect(await zusammenfassungVersenden()).toEqual({ gesendet: 0, fehlgeschlagen: 0 });
|
||
expect(await postfachLesen()).toHaveLength(1);
|
||
});
|
||
|
||
it("STARTTLS ist Pflicht, wenn eingestellt – ohne TLS kein Versand", async () => {
|
||
await einstellen("STARTTLS");
|
||
const fehler = await testVersenden("EMAIL", AKTEUR);
|
||
expect(fehler).toMatch(/^SMTP: /);
|
||
const zeile = await prisma.zustellung.findFirstOrThrow({ where: { kanal: "EMAIL" } });
|
||
expect(zeile.ergebnis).toBe("FEHLGESCHLAGEN");
|
||
});
|
||
});
|
||
|
||
describe("Webhook an lokalen Empfänger", () => {
|
||
it("signiert den Body mit HMAC-SHA256", async () => {
|
||
await einstellen();
|
||
expect(await testVersenden("WEBHOOK", AKTEUR)).toBeNull();
|
||
expect(empfangen).toHaveLength(1);
|
||
const { kopf, koerper } = empfangen[0]!;
|
||
expect(kopf["content-type"]).toMatch(/^application\/json/);
|
||
expect(kopf["x-sshkv-ereignis"]).toBe("test");
|
||
expect(kopf["x-sshkv-signatur"]).toBe(signatur(koerper, GEHEIMNIS));
|
||
expect(JSON.parse(koerper)).toMatchObject({ ereignis: "test", betreff: "[sshkv] Testnachricht" });
|
||
});
|
||
|
||
it("geänderter Host-Key geht sofort raus; HTTP 500 wird später wiederholt", async () => {
|
||
await einstellen();
|
||
const host = await prisma.host.create({
|
||
data: { name: "srv-hook", adresse: "10.2.2.2", bootstrapAm: new Date() },
|
||
});
|
||
await prisma.abweichung.create({
|
||
data: {
|
||
hostId: host.id,
|
||
art: "HOSTKEY_GEAENDERT",
|
||
fingerprint: "SHA256:neu",
|
||
details: { erwartet: "SHA256:alt", algorithmus: "ssh-ed25519" },
|
||
},
|
||
});
|
||
antwortStatus = 500;
|
||
const jetzt = new Date();
|
||
expect(await taktVersenden(jetzt)).toEqual({ gesendet: 1, fehlgeschlagen: 1 });
|
||
const zeile = await prisma.zustellung.findFirstOrThrow({ where: { kanal: "WEBHOOK" } });
|
||
expect(zeile).toMatchObject({ ergebnis: "FEHLGESCHLAGEN", fehler: "Webhook antwortet mit HTTP 500." });
|
||
|
||
antwortStatus = 204;
|
||
empfangen = [];
|
||
expect(await taktVersenden(new Date(jetzt.getTime() + 6 * 60_000))).toEqual({
|
||
gesendet: 1,
|
||
fehlgeschlagen: 0,
|
||
});
|
||
const json = JSON.parse(empfangen[0]!.koerper) as Record<string, unknown>;
|
||
expect(json).toMatchObject({
|
||
ereignis: "hostkey.geaendert",
|
||
dringend: true,
|
||
host: { name: "srv-hook", adresse: "10.2.2.2" },
|
||
befunde: [{ fingerprint: "SHA256:neu", erwartet: "SHA256:alt" }],
|
||
});
|
||
expect(typeof json["zeitstempel"]).toBe("string");
|
||
expect(empfangen[0]!.kopf["x-sshkv-signatur"]).toBe(signatur(empfangen[0]!.koerper, GEHEIMNIS));
|
||
// Die E-Mail ging beim ersten Mal durch und kommt nicht noch einmal.
|
||
expect(await postfachLesen()).toHaveLength(1);
|
||
});
|
||
});
|