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>
220 lines
7.3 KiB
TypeScript
220 lines
7.3 KiB
TypeScript
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();
|
||
});
|
||
});
|