import { createSocket, type Socket } from "node:dgram"; import { createServer, type Server } from "node:net"; import type { AddressInfo } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; import { HOECHSTLAENGE, sdWertEscapen, syslogKonfigAus, syslogNachricht, syslogSender, syslogZielText, tcpRahmen, type SyslogEintrag, } from "@/lib/syslog"; /** Syslog-Weiterleitung (#027): Format nach RFC 5424, Escaping, Laenge, Versand. */ const EINTRAG: SyslogEintrag = { id: 42n, zeitpunkt: new Date("2026-10-12T14:03:07.123Z"), akteur: "Florian", aktion: "authorized_keys.geschrieben", ergebnis: "ERFOLG", hostName: "srv-pve01", zielbenutzer: "root", fingerprint: "SHA256:abc", hash: "f00", }; const KONFIG = { appName: "sshkv", facility: 10 }; const FEST = { rechner: "vault", prozess: 7 }; describe("syslogKonfigAus", () => { it("ist ohne Host aus", () => { expect(syslogKonfigAus({})).toBeNull(); expect(syslogKonfigAus({ SSHKV_SYSLOG_HOST: " " })).toBeNull(); }); it("setzt Standardwerte", () => { expect(syslogKonfigAus({ SSHKV_SYSLOG_HOST: "log.intern" })).toEqual({ host: "log.intern", port: 514, protokoll: "udp", appName: "sshkv", facility: 10, }); }); it("liest Port, Protokoll, App-Name und Facility", () => { const konfig = syslogKonfigAus({ SSHKV_SYSLOG_HOST: "10.0.0.5", SSHKV_SYSLOG_PORT: "6514", SSHKV_SYSLOG_PROTOKOLL: "TCP", SSHKV_SYSLOG_APPNAME: "vault prod", SSHKV_SYSLOG_FACILITY: "local3", }); expect(konfig).toMatchObject({ port: 6514, protokoll: "tcp", appName: "vault_prod", facility: 19 }); expect(syslogZielText(konfig!)).toBe("tcp://10.0.0.5:6514"); }); it("faellt bei Unsinn auf die Standards zurueck", () => { expect( syslogKonfigAus({ SSHKV_SYSLOG_HOST: "x", SSHKV_SYSLOG_PORT: "99999", SSHKV_SYSLOG_FACILITY: "quatsch", }), ).toMatchObject({ port: 514, facility: 10, protokoll: "udp" }); }); }); describe("syslogNachricht", () => { it("baut eine Zeile nach RFC 5424 mit strukturierten Daten", () => { expect(syslogNachricht(EINTRAG, KONFIG, FEST)).toBe( "<85>1 2026-10-12T14:03:07.123Z vault sshkv 7 authorized_keys.geschrieben " + '[sshkv@32473 id="42" akteur="Florian" aktion="authorized_keys.geschrieben" ergebnis="ERFOLG" ' + 'host="srv-pve01" zielbenutzer="root" fingerprint="SHA256:abc" hash="f00"] ' + "Florian: authorized_keys.geschrieben ERFOLG auf srv-pve01", ); }); it("leitet die Severity aus dem Ergebnis ab", () => { // authpriv (10) * 8 = 80; notice 5, warning 4, err 3 expect(syslogNachricht({ ...EINTRAG, ergebnis: "VERWEIGERT" }, KONFIG, FEST)).toMatch(/^<84>1 /); expect(syslogNachricht({ ...EINTRAG, ergebnis: "FEHLER" }, KONFIG, FEST)).toMatch(/^<83>1 /); expect(syslogNachricht(EINTRAG, { appName: "sshkv", facility: 16 }, FEST)).toMatch(/^<133>1 /); }); it("escapt Anfuehrungszeichen, Backslash und eckige Klammer", () => { expect(sdWertEscapen('a"b\\c]d')).toBe('a\\"b\\\\c\\]d'); const zeile = syslogNachricht({ ...EINTRAG, akteur: 'Eve "]x', zielbenutzer: "a\nb" }, KONFIG, FEST); expect(zeile).toContain('akteur="Eve \\"\\]x"'); expect(zeile).toContain('zielbenutzer="a b"'); expect(zeile).not.toContain("\n"); }); it("laesst leere Felder weg", () => { const zeile = syslogNachricht( { ...EINTRAG, hostName: null, zielbenutzer: null, fingerprint: undefined }, KONFIG, FEST, ); expect(zeile).not.toContain("host="); expect(zeile).not.toContain("fingerprint="); }); it("bleibt unter der Hoechstlaenge, auch mit langen Werten", () => { const lang = "ä".repeat(5000); const zeile = syslogNachricht( { ...EINTRAG, akteur: lang, hostName: '"'.repeat(3000), zielbenutzer: lang, fingerprint: lang }, KONFIG, FEST, ); expect(Buffer.byteLength(zeile, "utf8")).toBeLessThanOrEqual(HOECHSTLAENGE); // Strukturierte Daten bleiben vollstaendig und geschlossen. expect(zeile).toMatch(/hash="f00"\] /); // Kein halbes Escape am Ende eines Werts. expect(zeile).not.toMatch(/[^\\]\\"\s/); }); it("rahmt fuer TCP mit Oktettzaehlung", () => { expect(tcpRahmen("<85>1 ä").toString("utf8")).toBe("8 <85>1 ä"); }); }); describe("syslogSender", () => { let empfaenger: Socket | undefined; let server: Server | undefined; afterEach(() => { empfaenger?.close(); empfaenger = undefined; server?.close(); server = undefined; }); async function udpEmpfaenger(): Promise<{ port: number; naechste: () => Promise }> { const socket = createSocket("udp4"); empfaenger = socket; const eingang: string[] = []; const wartende: ((text: string) => void)[] = []; socket.on("message", (daten) => { const text = daten.toString("utf8"); const warten = wartende.shift(); if (warten) warten(text); else eingang.push(text); }); await new Promise((fertig) => socket.bind(0, "127.0.0.1", fertig)); return { port: (socket.address() as AddressInfo).port, naechste: () => new Promise((fertig) => { const vorhanden = eingang.shift(); if (vorhanden) fertig(vorhanden); else wartende.push(fertig); }), }; } it("sendet per UDP an einen lokalen Empfaenger", async () => { const { port, naechste } = await udpEmpfaenger(); const sender = syslogSender({ host: "127.0.0.1", port, protokoll: "udp", appName: "sshkv", facility: 10, }); sender.senden(EINTRAG); const zeile = await naechste(); sender.schliessen(); expect(zeile).toMatch( /^<85>1 2026-10-12T14:03:07.123Z \S+ sshkv \d+ authorized_keys.geschrieben \[sshkv@32473 /, ); expect(zeile).toContain('hash="f00"'); }); it("sendet per TCP mit Rahmen", async () => { const eingang = new Promise((fertig) => { server = createServer((verbindung) => { verbindung.on("data", (daten) => fertig(daten.toString("utf8"))); }); }); await new Promise((fertig) => server!.listen(0, "127.0.0.1", fertig)); const port = (server!.address() as AddressInfo).port; const sender = syslogSender({ host: "127.0.0.1", port, protokoll: "tcp", appName: "sshkv", facility: 10, }); sender.senden(EINTRAG); const text = await eingang; sender.schliessen(); const [laenge, ...rest] = text.split(" "); expect(Number(laenge)).toBe(Buffer.byteLength(rest.join(" "), "utf8")); expect(rest.join(" ")).toMatch(/^<85>1 /); }); it("wirft nie und meldet Fehler hoechstens einmal je Minute", async () => { const melden = vi.fn(); let uhr = 0; // Port 1 auf localhost: niemand hoert zu, die Verbindung scheitert sofort. const sender = syslogSender( { host: "127.0.0.1", port: 1, protokoll: "tcp", appName: "sshkv", facility: 10 }, melden, () => uhr, ); expect(() => sender.senden(EINTRAG)).not.toThrow(); await vi.waitFor(() => expect(melden).toHaveBeenCalledTimes(1)); uhr = 30_000; sender.senden(EINTRAG); await new Promise((fertig) => setTimeout(fertig, 100)); expect(melden).toHaveBeenCalledTimes(1); uhr = 61_000; sender.senden(EINTRAG); await vi.waitFor(() => expect(melden).toHaveBeenCalledTimes(2)); sender.schliessen(); }); });