diff --git a/.env.example b/.env.example index c820c32..754080d 100644 --- a/.env.example +++ b/.env.example @@ -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). diff --git a/docker-compose.yml b/docker-compose.yml index dbb905e..ee57d20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. diff --git a/src/lib/syslog.ts b/src/lib/syslog.ts new file mode 100644 index 0000000..c261c53 --- /dev/null +++ b/src/lib/syslog.ts @@ -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> = { + 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; + }, + }; +} diff --git a/tests/syslog.test.ts b/tests/syslog.test.ts new file mode 100644 index 0000000..19d682d --- /dev/null +++ b/tests/syslog.test.ts @@ -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 }> { + 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((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((fertig) => { + server = createServer((verbindung) => { + verbindung.on("data", (daten) => fertig(daten.toString("utf8"))); + }); + }); + await new Promise((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(); + }); +});