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:
@@ -60,6 +60,21 @@ SSHKV_BREAKGLASS_PASSWORD=
|
||||
# Zeitplan fuer den Abgleich aller Hosts (cron)
|
||||
SSHKV_SYNC_CRON=30 2 * * *
|
||||
|
||||
# --- Syslog (optional, #027) ---------------------------------------------------
|
||||
# Jeder Protokolleintrag geht zusaetzlich als RFC-5424-Zeile an einen Syslog-Server – die
|
||||
# Kopie ausserhalb von sshkv. Ohne SSHKV_SYSLOG_HOST ist die Weiterleitung aus. Es gehen nur
|
||||
# Akteur, Aktion, Ergebnis, Host, Zielbenutzer, Fingerprint und Hash hinaus, nie Details/Diff.
|
||||
# Web und Worker protokollieren beide; beide brauchen die Variablen.
|
||||
# SSHKV_SYSLOG_HOST=syslog.intern
|
||||
# Standard 514
|
||||
# SSHKV_SYSLOG_PORT=514
|
||||
# udp (Standard) oder tcp (Oktettzaehlung nach RFC 6587)
|
||||
# SSHKV_SYSLOG_PROTOKOLL=udp
|
||||
# APP-NAME im Syslog-Kopf (Standard sshkv)
|
||||
# SSHKV_SYSLOG_APPNAME=sshkv
|
||||
# Facility (Standard authpriv; auch auth, daemon, user, local0 … local7)
|
||||
# SSHKV_SYSLOG_FACILITY=authpriv
|
||||
|
||||
# --- Integrationstests (#011) ---------------------------------------------------
|
||||
# SSH-Testziele aus docker-compose.test.yml. Die Compose-Datei liest dieselben Variablen
|
||||
# fuer die Port-Bindung (host:port, nur 127.0.0.1).
|
||||
|
||||
@@ -64,6 +64,12 @@ services:
|
||||
SSHKV_OIDC_GROUP_ADMIN: ${SSHKV_OIDC_GROUP_ADMIN:-sshkv-admins}
|
||||
SSHKV_OIDC_GROUP_OPERATOR: ${SSHKV_OIDC_GROUP_OPERATOR:-sshkv-operators}
|
||||
SSHKV_OIDC_GROUP_READER: ${SSHKV_OIDC_GROUP_READER:-sshkv-readers}
|
||||
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
|
||||
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
|
||||
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
|
||||
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
|
||||
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
|
||||
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
|
||||
# Absichtlich KEIN Master-Key: eine Luecke im Webprozess allein entschluesselt keine
|
||||
# Private Keys. Nach aussen braucht er nur den OIDC-Anbieter.
|
||||
ports:
|
||||
@@ -94,6 +100,12 @@ services:
|
||||
SSHKV_APP_SECRET: ${SSHKV_APP_SECRET}
|
||||
SSHKV_MASTER_KEY_FILE: /run/secrets/master_key
|
||||
SSHKV_SYNC_CRON: ${SSHKV_SYNC_CRON:-30 2 * * *}
|
||||
# Syslog-Kopie des Protokolls (#027) – optional, leer heisst aus.
|
||||
SSHKV_SYSLOG_HOST: ${SSHKV_SYSLOG_HOST:-}
|
||||
SSHKV_SYSLOG_PORT: ${SSHKV_SYSLOG_PORT:-514}
|
||||
SSHKV_SYSLOG_PROTOKOLL: ${SSHKV_SYSLOG_PROTOKOLL:-udp}
|
||||
SSHKV_SYSLOG_APPNAME: ${SSHKV_SYSLOG_APPNAME:-sshkv}
|
||||
SSHKV_SYSLOG_FACILITY: ${SSHKV_SYSLOG_FACILITY:-authpriv}
|
||||
secrets:
|
||||
- master_key
|
||||
# Der Worker braucht die Datenbank und den Weg zu den Hosts.
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { createSocket, type Socket as UdpSocket } from "node:dgram";
|
||||
import { hostname } from "node:os";
|
||||
import { connect, type Socket as TcpSocket } from "node:net";
|
||||
|
||||
/**
|
||||
* Weiterleitung des Protokolls an Syslog (#027), RFC 5424 mit strukturierten Daten.
|
||||
*
|
||||
* Bewusst klein und ohne Abhaengigkeit: ein paar Zeilen Format, ein UDP- oder TCP-Socket.
|
||||
* Die Kopie beim Syslog-Server ist der zweite, vom sshkv-Host unabhaengige Beleg – wer die
|
||||
* Datenbank uebernimmt, kann die Hash-Kette zwar neu rechnen, aber nicht zurueckholen, was
|
||||
* schon beim Syslog liegt (PLAN §3).
|
||||
*
|
||||
* Es gehen nur die Felder hinaus, die das Protokoll ohnehin geschwaerzt speichert und die
|
||||
* Uebersicht zeigt – `details` und `diff` nie, dort koennten Pfade und Dateiinhalte stehen.
|
||||
*/
|
||||
|
||||
export type SyslogProtokoll = "udp" | "tcp";
|
||||
|
||||
export type SyslogKonfig = {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly protokoll: SyslogProtokoll;
|
||||
readonly appName: string;
|
||||
/** Facility-Nummer nach RFC 5424 (authpriv = 10). */
|
||||
readonly facility: number;
|
||||
};
|
||||
|
||||
export const FACILITIES: Readonly<Record<string, number>> = {
|
||||
kern: 0,
|
||||
user: 1,
|
||||
daemon: 3,
|
||||
auth: 4,
|
||||
syslog: 5,
|
||||
authpriv: 10,
|
||||
local0: 16,
|
||||
local1: 17,
|
||||
local2: 18,
|
||||
local3: 19,
|
||||
local4: 20,
|
||||
local5: 21,
|
||||
local6: 22,
|
||||
local7: 23,
|
||||
};
|
||||
|
||||
/** Severity aus dem Ergebnis: Erfolg ist bemerkenswert, Verweigerung eine Warnung. */
|
||||
export const SEVERITY = { ERFOLG: 5, VERWEIGERT: 4, FEHLER: 3 } as const;
|
||||
|
||||
export type SyslogEintrag = {
|
||||
readonly id?: bigint | string | null;
|
||||
readonly zeitpunkt: Date;
|
||||
readonly akteur: string;
|
||||
readonly aktion: string;
|
||||
readonly ergebnis: keyof typeof SEVERITY;
|
||||
readonly hostName?: string | null;
|
||||
readonly zielbenutzer?: string | null;
|
||||
readonly fingerprint?: string | null;
|
||||
readonly hash?: string | null;
|
||||
};
|
||||
|
||||
/** IANA-Private-Enterprise-Number fuer Beispiele/Eigenbau ist 32473 (RFC 5612). */
|
||||
export const SD_ID = "sshkv@32473";
|
||||
|
||||
/** RFC 5426 verlangt mindestens 480, empfiehlt 2048 Byte; 2048 passt auch in UDP sicher. */
|
||||
export const HOECHSTLAENGE = 2048;
|
||||
/** Je SD-Wert in Byte vor dem Escapen; acht Werte passen so mit Kopf in 2048 Byte. */
|
||||
const WERT_HOECHSTBYTES = 120;
|
||||
|
||||
/**
|
||||
* Liest die Konfiguration aus der Umgebung. Ohne `SSHKV_SYSLOG_HOST` ist Syslog aus; Fehler
|
||||
* in den uebrigen Werten fallen auf die Standards zurueck, statt den Start zu verhindern.
|
||||
*/
|
||||
export function syslogKonfigAus(umgebung: Record<string, string | undefined>): SyslogKonfig | null {
|
||||
const host = umgebung["SSHKV_SYSLOG_HOST"]?.trim();
|
||||
if (!host) return null;
|
||||
const port = Number(umgebung["SSHKV_SYSLOG_PORT"] ?? 514);
|
||||
const protokoll = umgebung["SSHKV_SYSLOG_PROTOKOLL"]?.trim().toLowerCase() === "tcp" ? "tcp" : "udp";
|
||||
const appName = asciiDruckbar(umgebung["SSHKV_SYSLOG_APPNAME"]?.trim() || "sshkv", 48) || "sshkv";
|
||||
const facilityName = umgebung["SSHKV_SYSLOG_FACILITY"]?.trim().toLowerCase() || "authpriv";
|
||||
return {
|
||||
host,
|
||||
port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 514,
|
||||
protokoll,
|
||||
appName,
|
||||
facility: FACILITIES[facilityName] ?? FACILITIES["authpriv"]!,
|
||||
};
|
||||
}
|
||||
|
||||
/** Kopffelder duerfen nur druckbares ASCII ohne Leerzeichen enthalten (RFC 5424 §6). */
|
||||
function asciiDruckbar(wert: string, laenge: number): string {
|
||||
return wert.replace(/[^\x21-\x7e]/g, "_").slice(0, laenge);
|
||||
}
|
||||
|
||||
/** Werte in SD-PARAM: `"`, `\` und `]` mit Backslash (RFC 5424 §6.3.3). */
|
||||
export function sdWertEscapen(wert: string): string {
|
||||
return wert.replace(/["\\\]]/g, (zeichen) => `\\${zeichen}`);
|
||||
}
|
||||
|
||||
/** Kuerzt vor dem Escapen, damit kein Backslash ohne sein Zeichen am Ende steht. */
|
||||
function sdWert(wert: string | null | undefined, bytes: number): string {
|
||||
// Steuerzeichen (Zeilenumbrueche) wuerden manche Empfaenger zum Zeilenende verleiten.
|
||||
const sauber = (wert ?? "").replace(/[\x00-\x1f\x7f]/g, " ");
|
||||
return sdWertEscapen(textKuerzen(sauber, bytes));
|
||||
}
|
||||
|
||||
/** Kuerzt auf hoechstens `bytes` UTF-8-Bytes, ohne ein Zeichen zu zerschneiden. */
|
||||
export function textKuerzen(text: string, bytes: number): string {
|
||||
if (Buffer.byteLength(text, "utf8") <= bytes) return text;
|
||||
let ergebnis = "";
|
||||
let laenge = 0;
|
||||
for (const zeichen of text) {
|
||||
const groesse = Buffer.byteLength(zeichen, "utf8");
|
||||
if (laenge + groesse + 3 > bytes) break;
|
||||
ergebnis += zeichen;
|
||||
laenge += groesse;
|
||||
}
|
||||
return `${ergebnis}...`;
|
||||
}
|
||||
|
||||
/** Baut eine vollstaendige RFC-5424-Zeile (ohne TCP-Rahmen). */
|
||||
export function syslogNachricht(
|
||||
eintrag: SyslogEintrag,
|
||||
konfig: Pick<SyslogKonfig, "appName" | "facility">,
|
||||
optionen: { rechner?: string; prozess?: number; text?: string } = {},
|
||||
): string {
|
||||
const pri = konfig.facility * 8 + SEVERITY[eintrag.ergebnis];
|
||||
const rechner = asciiDruckbar(optionen.rechner ?? hostname(), 255) || "-";
|
||||
const prozess = String(optionen.prozess ?? process.pid);
|
||||
const msgId = asciiDruckbar(eintrag.aktion, 32) || "-";
|
||||
const parameter: [string, string | null | undefined][] = [
|
||||
["id", eintrag.id == null ? null : String(eintrag.id)],
|
||||
["akteur", eintrag.akteur],
|
||||
["aktion", eintrag.aktion],
|
||||
["ergebnis", eintrag.ergebnis],
|
||||
["host", eintrag.hostName],
|
||||
["zielbenutzer", eintrag.zielbenutzer],
|
||||
["fingerprint", eintrag.fingerprint],
|
||||
["hash", eintrag.hash],
|
||||
];
|
||||
const kopfMit = (wertBytes: number) => {
|
||||
const sd = `[${SD_ID}${parameter
|
||||
.filter(([, wert]) => wert != null && wert !== "")
|
||||
.map(([name, wert]) => ` ${name}="${sdWert(wert, wertBytes)}"`)
|
||||
.join("")}]`;
|
||||
return `<${pri}>1 ${eintrag.zeitpunkt.toISOString()} ${rechner} ${konfig.appName} ${prozess} ${msgId} ${sd}`;
|
||||
};
|
||||
let kopf = kopfMit(WERT_HOECHSTBYTES);
|
||||
// Nur bei absichtlich langen Werten voller Sonderzeichen: dann alle Werte knapper.
|
||||
if (Buffer.byteLength(kopf, "utf8") > HOECHSTLAENGE - 64) kopf = kopfMit(32);
|
||||
const text =
|
||||
optionen.text ??
|
||||
`${eintrag.akteur}: ${eintrag.aktion} ${eintrag.ergebnis}${eintrag.hostName ? ` auf ${eintrag.hostName}` : ""}`;
|
||||
// Ohne BOM gilt MSG als beliebige Bytes; mit BOM wissen Empfaenger, dass es UTF-8 ist.
|
||||
const rest = HOECHSTLAENGE - Buffer.byteLength(kopf, "utf8") - 4;
|
||||
return `${kopf} \uFEFF${textKuerzen(text.replace(/[\r\n]+/g, " "), rest)}`;
|
||||
}
|
||||
|
||||
/** Oktettzaehlung nach RFC 6587 – robuster als Zeilenumbruch als Trenner. */
|
||||
export function tcpRahmen(nachricht: string): Buffer {
|
||||
const daten = Buffer.from(nachricht, "utf8");
|
||||
return Buffer.concat([Buffer.from(`${daten.length} `, "ascii"), daten]);
|
||||
}
|
||||
|
||||
/** Beschreibung fuer die Oberflaeche – Ziel ohne Zugangsdaten (es gibt keine). */
|
||||
export function syslogZielText(konfig: SyslogKonfig): string {
|
||||
const host = konfig.host.includes(":") ? `[${konfig.host}]` : konfig.host;
|
||||
return `${konfig.protokoll}://${host}:${konfig.port}`;
|
||||
}
|
||||
|
||||
export type SyslogSender = {
|
||||
senden(eintrag: SyslogEintrag): void;
|
||||
schliessen(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sender mit Feuer-und-vergessen-Semantik: `senden()` wirft nie und wartet auf nichts.
|
||||
* Fehler gehen an `fehlerMelden`, und zwar hoechstens einmal je Minute – ein ausgefallener
|
||||
* Syslog-Server soll das Log nicht fluten und darf nie eine Aktion verhindern.
|
||||
*/
|
||||
export function syslogSender(
|
||||
konfig: SyslogKonfig,
|
||||
fehlerMelden: (fehler: unknown) => void = (fehler) =>
|
||||
console.error("[sshkv] Syslog nicht erreichbar:", fehler instanceof Error ? fehler.message : fehler),
|
||||
jetzt: () => number = Date.now,
|
||||
): SyslogSender {
|
||||
let zuletztGemeldet = -Infinity;
|
||||
const melden = (fehler: unknown) => {
|
||||
const t = jetzt();
|
||||
if (t - zuletztGemeldet < 60_000) return;
|
||||
zuletztGemeldet = t;
|
||||
try {
|
||||
fehlerMelden(fehler);
|
||||
} catch {
|
||||
// Auch ein kaputter Logger darf hier nichts abbrechen.
|
||||
}
|
||||
};
|
||||
|
||||
let udp: UdpSocket | null = null;
|
||||
let tcp: TcpSocket | null = null;
|
||||
|
||||
const udpSocket = () => {
|
||||
if (udp) return udp;
|
||||
const socket = createSocket(konfig.host.includes(":") ? "udp6" : "udp4");
|
||||
socket.on("error", (fehler) => {
|
||||
melden(fehler);
|
||||
socket.close();
|
||||
if (udp === socket) udp = null;
|
||||
});
|
||||
// Der Socket soll den Prozess (Worker, Tests) nicht am Beenden hindern.
|
||||
socket.unref();
|
||||
udp = socket;
|
||||
return socket;
|
||||
};
|
||||
|
||||
const tcpSocket = () => {
|
||||
if (tcp && !tcp.destroyed) return tcp;
|
||||
const socket = connect({ host: konfig.host, port: konfig.port });
|
||||
socket.setKeepAlive(true);
|
||||
socket.on("error", (fehler) => {
|
||||
melden(fehler);
|
||||
socket.destroy();
|
||||
});
|
||||
socket.on("close", () => {
|
||||
if (tcp === socket) tcp = null;
|
||||
});
|
||||
socket.unref();
|
||||
tcp = socket;
|
||||
return socket;
|
||||
};
|
||||
|
||||
return {
|
||||
senden(eintrag) {
|
||||
try {
|
||||
const nachricht = syslogNachricht(eintrag, konfig);
|
||||
if (konfig.protokoll === "tcp") {
|
||||
// Schreibt vor dem Verbindungsaufbau in den Puffer des Sockets; bei Fehlern gehen
|
||||
// diese Zeilen verloren – die Datenbank bleibt die Quelle der Wahrheit.
|
||||
tcpSocket().write(tcpRahmen(nachricht));
|
||||
} else {
|
||||
const daten = Buffer.from(nachricht, "utf8");
|
||||
udpSocket().send(daten, konfig.port, konfig.host, (fehler) => {
|
||||
if (fehler) melden(fehler);
|
||||
});
|
||||
}
|
||||
} catch (fehler) {
|
||||
melden(fehler);
|
||||
}
|
||||
},
|
||||
schliessen() {
|
||||
udp?.close();
|
||||
udp = null;
|
||||
tcp?.end();
|
||||
tcp = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user