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> = { 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): 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, 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; }, }; }