Files
florian.wildeandClaude Opus 5.5 7bb2ca6a51 feat(protokoll): Syslog-Sender nach RFC 5424 ohne Abhaengigkeit (#027)
Die Kopie beim Syslog-Server ist der vom sshkv-Host unabhaengige Beleg:
wer die Datenbank uebernimmt, kann die Hash-Kette neu rechnen, aber nicht
zurueckholen, was schon beim Syslog liegt. Eigene kleine Implementierung
mit node:dgram/node:net statt einer Bibliothek, weil das Format
ueberschaubar ist und jede Abhaengigkeit im Sicherheitskern zaehlt.

Strukturierte Daten werden nach RFC 5424 escaped und begrenzt (2048 Byte),
Severity folgt dem Ergebnis, Facility standardmaessig authpriv. Der Sender
wirft nie und meldet Fehler hoechstens einmal je Minute, damit ein
ausgefallener Syslog-Server weder Aktionen verhindert noch das Log flutet.
Web und Worker bekommen die Variablen, weil beide protokollieren.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:47:30 +02:00

220 lines
7.3 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> }> {
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<void>((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<string>((fertig) => {
server = createServer((verbindung) => {
verbindung.on("data", (daten) => fertig(daten.toString("utf8")));
});
});
await new Promise<void>((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();
});
});