Files
sshkeyvault/src/lib/syslog.ts
T
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

256 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
},
};
}