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>
This commit is contained in:
2026-10-12 16:47:30 +02:00
co-authored by Claude Opus 5.5
parent 83bd9e0922
commit 7bb2ca6a51
4 changed files with 501 additions and 0 deletions
+219
View File
@@ -0,0 +1,219 @@
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();
});
});