Merge branch 'main' into phase/029-handbuch

This commit is contained in:
2026-10-12 16:49:27 +02:00
21 changed files with 2263 additions and 16 deletions
+15
View File
@@ -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).
+12
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

+25 -4
View File
@@ -1,19 +1,40 @@
# #027 Protokollansicht, CSV-Export, Syslog
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.5.0
**Labels:** ui, backend
**Geschätzt:** 2 h **Tatsächlich:** –
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
## Ziel
Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog.
## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert
- [x] `/protokoll` statt Platzhalter, jede Rolle liest: Tabelle mit Zeitpunkt (TT.MM.JJJJ HH:MM:SS), Aktion (lesbar, farbig), Akteur, Host, Zielbenutzer, Fingerprint (gekürzt), Ergebnis; unter `md` Karten
- [x] Zeile aufklappbar: Diff im Diff-Box-Stil, Details als Liste (Werte als JSON), Hash und Vorgänger-Hash
- [x] Lesbare Texte für alle Aktionen in `src/lib/protokoll-aktionen.ts`, gruppiert (Anmeldung, Host-Key, Ausrollen, Schlüssel, Widerruf/Rotation, Zielkonto, Abgleich, Hosts, Personen und Vorlagen, Protokoll)
- [x] Filter per GET: Zeitraum (Berliner Kalendertage), Aktionsgruppe, Akteur, Host, Ergebnis, Freitext (Fingerprint/Host/Benutzer)
- [x] 50 je Seite, „Ältere laden“ hängt per Server Action an; Cursor über `id`, kein `OFFSET`; `?vor=<id>` als Einstieg
- [x] Kopfzeile mit Anzahl und Syslog-Ziel; „Kette prüfen“ (Server Action) mit Ergebnis-Zwischenspeicher für 5 Minuten, Warnung mit Link auf die erste fehlerhafte ID
- [x] CSV-Export `/api/protokoll.csv`: eigene Rechteprüfung, gleiche Filter, gestreamt in Stapeln zu 500, Formel-Entschärfung über `src/lib/csv.ts`, Export selbst als `protokoll.exportiert` protokolliert
- [x] Kein Lösch-/Bearbeitungsweg; struktureller Test gegen `protokoll.update/delete…` und `UPDATE/DELETE/TRUNCATE "Protokoll"` in `src/`
- [x] Syslog nach RFC 5424 (`src/lib/syslog.ts`, UDP oder TCP mit Oktettzählung, ohne neue Abhängigkeit), eingehängt in `protokollieren()` nach dem Schreiben
- [x] `.env.example` und `docker-compose.yml` (web und worker) um `SSHKV_SYSLOG_*`
- [x] Tests: Query/Cursor/Filter, CSV-Route, Kettenprüfung, Syslog (Format, Escaping, Länge, UDP-/TCP-Empfänger), Aktionstexte, Struktur; E2E `tests/e2e/protokoll.spec.ts`
- [x] Screenshots `protokoll-desktop.png` und `protokoll-mobil.png`
## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert
- [x] Jede `aktion: "…"` im Quellcode hat einen lesbaren Text (Test)
- [x] Filter und „Ältere laden“ liefern weder Lücken noch Doppelte, auch wenn zwischendurch Einträge dazukommen
- [x] CSV ohne Sitzung → 403; Kopfzeile `id;zeitpunkt;akteur;aktion;…;hash`; große Protokolle werden in Stapeln gelesen
- [x] Manipulierte Zeile → Warnung mit ID; unversehrte Kette → „Hash-Kette unversehrt“
- [x] Ausgefallener Syslog-Server verhindert keine Aktion; Fehler höchstens einmal je Minute im Log
- [x] 1440 und 390 px ohne Querscrollen
## Abhängigkeiten
#005
## Notizen / Entscheidungen
- **Kettenprüfung nur auf Knopfdruck, 5 Minuten zwischengespeichert (je Prozess).** `protokoll_kette_pruefen()` rechnet SHA-256 über jede Zeile und wächst linear mit dem Protokoll; beim bloßen Seitenaufruf wäre das bei 100 000 Einträgen spürbar. Gleichzeitige Klicks teilen sich eine laufende Prüfung. Ein Bruch wird selbst als `protokoll.kette_beschaedigt` (FEHLER) protokolliert, damit er auch beim Syslog ankommt.
- **CSV gestreamt** über einen `ReadableStream` mit `pull`: je Stapel 500 Zeilen (Cursor über `id`), der nächste Stapel erst, wenn der Client den vorigen abgenommen hat. Der Exporteintrag wird vor dem ersten Byte geschrieben – scheitert er, gibt es keinen Export.
- **IDs sind nicht lückenlos** (Spalten-Default und Trigger ziehen beide aus der Sequenz); Cursor und Tests verlassen sich nur auf die Ordnung.
- **Syslog:** RFC 5424, SD-ID `sshkv@32473`, Parameter id/akteur/aktion/ergebnis/host/zielbenutzer/fingerprint/hash (escaped nach §6.3.3, Steuerzeichen ersetzt, je Wert ≤ 120 Byte, Zeile ≤ 2048 Byte), MSGID = Aktion, Severity ERFOLG=notice, VERWEIGERT=warning, FEHLER=err, Facility standardmäßig `authpriv` (`SSHKV_SYSLOG_FACILITY`). Diff und Details gehen nie hinaus. Fire-and-forget nach dem `create`; innerhalb einer Transaktion kann ein späterer Rollback eine Syslog-Zeile ohne Gegenstück hinterlassen – das ist die harmlosere Richtung (ihr Hash fehlt dann in der Kette).
- `--ok` nur für erfolgreiche Aktionen, die etwas ändern; Anmeldungen, Abrufe und Export bleiben neutral, Fehler/Verweigert immer `--danger`.
+2
View File
@@ -49,6 +49,8 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
pfad: "/vorlagen",
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
},
// Audit-Log (#027): unter md Karten statt Tabelle, die Seite darf nicht seitwaerts scrollen.
{ name: "protokoll", pfad: "/protokoll" },
];
const ANSICHTEN = [
+191 -7
View File
@@ -1,18 +1,202 @@
import { Download, ScrollText, Search } from "lucide-react";
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { KettenPruefung } from "@/components/protokoll/KettenPruefung";
import { ProtokollListe } from "@/components/protokoll/ProtokollListe";
import { Button, buttonKlassen } from "@/components/ui/Button";
import { Auswahlliste, Feld } from "@/components/ui/Feld";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { AKTIONSGRUPPEN } from "@/lib/protokoll-aktionen";
import {
cursorAus,
ERGEBNIS_TEXT,
protokollFilterAktiv,
protokollFilterAus,
protokollFilterQuery,
} from "@/lib/protokoll-filter";
import { syslogZielText } from "@/lib/syslog";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { protokollAkteure, protokollSeite, protokollZaehlen } from "@/server/queries/protokoll";
import { ketteZwischengespeichert, syslogKonfig } from "@/server/services/protokoll";
export const metadata: Metadata = { title: "Audit-Log" };
export default async function Protokoll() {
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
/**
* Audit-Log (#027): jede Rolle darf lesen. Filter per GET, damit Links, „ältere laden“ und
* der CSV-Export denselben Ausschnitt meinen. Es gibt hier bewusst keinen Knopf, der einen
* Eintrag aendert oder loescht.
*/
export default async function Protokoll({ searchParams }: { searchParams: Suchparameter }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
await sitzungErforderlich("lesen", "/protokoll");
const parameter = await searchParams;
const filter = protokollFilterAus(parameter);
const vor = cursorAus(parameter["vor"]);
const [seite, zaehler, akteure] = await Promise.all([
protokollSeite(filter, vor),
protokollZaehlen(filter),
protokollAkteure(),
]);
const gefiltert = protokollFilterAktiv(filter);
const query = protokollFilterQuery(filter);
const kette = ketteZwischengespeichert();
const syslog = syslogKonfig();
return (
<Platzhalterseite
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Audit-Log"
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
phase={5}
issue="#027"
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen – nur lesbar, nicht änderbar"
aktionen={
<a href={`/api/protokoll.csv${query}`} download className={buttonKlassen("sekundaer")}>
<Download className="size-4" aria-hidden />
CSV exportieren
</a>
}
/>
<Karte innen="ohne" className="divide-border divide-y">
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 px-5 py-4 sm:px-6">
<p className="text-[14px]">
<span className="font-display text-text text-[22px] font-semibold tabular-nums">
{zaehler.gesamt.toLocaleString("de-DE")}
</span>{" "}
<span className="text-muted">Einträge insgesamt</span>
</p>
{gefiltert && (
<p className="text-muted text-[14px]">
<span className="text-text font-medium tabular-nums">
{zaehler.treffer.toLocaleString("de-DE")}
</span>{" "}
passen zum Filter
</p>
)}
<p className="text-muted text-[13px] sm:ml-auto">
Syslog:{" "}
{syslog ? (
<span className="text-text font-mono">{syslogZielText(syslog)}</span>
) : (
<span>nicht eingerichtet</span>
)}
</p>
</div>
<div className="px-5 py-4 sm:px-6">
<KettenPruefung
anfang={
kette
? {
fehlerhafteId: kette.fehlerhafteId,
eintraege: kette.eintraege,
geprueftAm: kette.geprueftAm.toISOString(),
}
: null
}
/>
</div>
</Karte>
<form method="get" role="search" className="grid grid-cols-2 items-end gap-3 lg:grid-cols-4">
<Feld
beschriftung="Suche"
name="q"
type="search"
defaultValue={filter.q}
placeholder="Fingerprint, Host oder Benutzer"
autoComplete="off"
spellCheck={false}
className="col-span-2"
/>
<Feld
beschriftung="Host"
name="host"
defaultValue={filter.host}
placeholder="z. B. srv-pve01"
autoComplete="off"
spellCheck={false}
mono
className="col-span-2 sm:col-span-1"
/>
<Auswahlliste
beschriftung="Akteur"
name="akteur"
defaultValue={filter.akteur}
className="col-span-2 sm:col-span-1"
>
<option value="">Alle</option>
{/* Ein gefilterter Name, der nicht mehr unter den haeufigsten ist, bleibt waehlbar. */}
{[...new Set([...akteure, ...(filter.akteur ? [filter.akteur] : [])])].map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Aktion" name="gruppe" defaultValue={filter.gruppe}>
<option value="">Alle Aktionen</option>
{Object.entries(AKTIONSGRUPPEN).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Ergebnis" name="ergebnis" defaultValue={filter.ergebnis}>
<option value="">Alle</option>
{Object.entries(ERGEBNIS_TEXT).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<Feld beschriftung="Von" name="von" type="date" defaultValue={filter.von} />
<Feld beschriftung="Bis" name="bis" type="date" defaultValue={filter.bis} />
<div className="col-span-2 flex gap-2.5 sm:justify-end lg:col-span-4">
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
Filtern
</Button>
{gefiltert && (
<Button href="/protokoll" className="flex-1 sm:flex-none">
Zurücksetzen
</Button>
)}
</div>
</form>
{vor !== undefined && (
<p className="text-muted text-[14px]">
Einträge älter als #{String(vor)}.{" "}
<a href={`/protokoll${query}`} className="text-accent underline-offset-2 hover:underline">
Zu den neuesten
</a>
</p>
)}
{seite.zeilen.length === 0 ? (
gefiltert ? (
<Leerzustand
icon={<Search className="size-5" />}
titel="Keine Treffer"
text="Kein Eintrag passt zu Suche und Filter."
aktion={<Button href="/protokoll">Filter zurücksetzen</Button>}
/>
) : (
<Leerzustand
icon={<ScrollText className="size-5" />}
titel="Noch keine Einträge"
text="Sobald jemand sich anmeldet oder etwas ändert, steht es hier."
/>
)
) : (
<Karte>
{/* key: neue Filter oder ein neuer Cursor beginnen eine frische Liste. */}
<ProtokollListe
key={`${query}|${String(vor ?? "")}`}
seite={seite}
query={query.replace(/^\?/, "")}
/>
</Karte>
)}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { CSV_BOM, csvZeile } from "@/lib/csv";
import { PROTOKOLL_CSV_KOPF, protokollCsvWerte, protokollFilterAus } from "@/lib/protokoll-filter";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { protokollStapelweise } from "@/server/queries/protokoll";
import { protokollieren } from "@/server/services/protokoll";
/**
* Protokoll als CSV (#027) mit denselben Filtern wie die Seite. Jede Rolle darf lesen; der
* Handler prueft das selbst.
*
* Gestreamt: die Datenbank liefert Stapel zu 500 Zeilen, jeder Stapel geht sofort hinaus.
* Ein Protokoll mit Hunderttausenden Eintraegen muss so nie ganz in den Speicher.
*/
export async function GET(anfrage: Request) {
let benutzer;
try {
({ benutzer } = await rechtErforderlich("lesen"));
} catch (fehler) {
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
throw fehler;
}
const filter = protokollFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams));
// Vor dem ersten Byte: wer das Protokoll mitnimmt, steht selbst darin – und scheitert das
// Schreiben, gibt es auch keinen Export.
await protokollieren({
akteur: { id: benutzer.id, name: benutzer.name },
aktion: "protokoll.exportiert",
ergebnis: "ERFOLG",
details: { filter: Object.fromEntries(Object.entries(filter).filter(([, wert]) => wert)) },
});
const kodierer = new TextEncoder();
const stapel = protokollStapelweise(filter);
const strom = new ReadableStream<Uint8Array>({
start(steuerung) {
steuerung.enqueue(kodierer.encode(CSV_BOM + csvZeile(PROTOKOLL_CSV_KOPF) + "\r\n"));
},
// pull statt Schleife in start: der naechste Stapel wird erst gelesen, wenn der Client
// den vorigen abgenommen hat (Gegendruck).
async pull(steuerung) {
try {
const { value, done } = await stapel.next();
if (done) {
steuerung.close();
return;
}
steuerung.enqueue(
kodierer.encode(value.map((z) => csvZeile(protokollCsvWerte(z)) + "\r\n").join("")),
);
} catch (fehler) {
steuerung.error(fehler);
}
},
async cancel() {
await stapel.return(undefined);
},
});
const tag = new Date().toISOString().slice(0, 10);
return new NextResponse(strom, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="sshkv-protokoll-${tag}.csv"`,
"Cache-Control": "no-store",
},
});
}
@@ -0,0 +1,81 @@
"use client";
import { ShieldAlert, ShieldCheck, ShieldQuestion } from "lucide-react";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/Button";
import { uhrzeit } from "@/lib/format";
import { kettePruefenAktion, type KettenAntwort } from "@/server/actions/protokoll";
/**
* Integritaet der Hash-Kette (#027). Geprueft wird nur auf Knopfdruck – die Pruefung liest
* jede Zeile; ein Ergebnis der letzten 5 Minuten bringt die Seite schon mit.
*/
export function KettenPruefung({ anfang }: { anfang: KettenAntwort | null }) {
const [ergebnis, setErgebnis] = useState<KettenAntwort | null>(anfang);
const [laeuft, starten] = useTransition();
const pruefen = () =>
starten(async () => {
setErgebnis(await kettePruefenAktion());
});
return (
<div
className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
aria-live="polite"
>
<Status ergebnis={ergebnis} />
<Button onClick={pruefen} disabled={laeuft} klein className="self-start sm:self-auto">
{laeuft ? "Prüft …" : "Kette prüfen"}
</Button>
</div>
);
}
function Status({ ergebnis }: { ergebnis: KettenAntwort | null }) {
if (!ergebnis) {
return (
<p className="text-muted flex items-center gap-2 text-[14px]">
<ShieldQuestion aria-hidden className="size-4 shrink-0" />
Hash-Kette noch nicht geprüft
</p>
);
}
if (ergebnis.fehler) {
return <p className="text-danger text-[14px]">{ergebnis.fehler}</p>;
}
const wann = ergebnis.geprueftAm ? ` um ${uhrzeit(ergebnis.geprueftAm)} Uhr` : "";
if (!ergebnis.fehlerhafteId) {
return (
<p className="text-ok flex items-center gap-2 text-[14px] font-medium">
<ShieldCheck aria-hidden className="size-4 shrink-0" />
<span>
Hash-Kette unversehrt
<span className="text-muted font-normal">
{" "}
· {ergebnis.eintraege?.toLocaleString("de-DE")} Einträge geprüft{wann}
</span>
</span>
</p>
);
}
const id = ergebnis.fehlerhafteId;
// Die Seite beginnt beim fehlerhaften Eintrag, der Anker hebt ihn hervor.
const ziel = `/protokoll?vor=${(BigInt(id) + 1n).toString()}#eintrag-${id}`;
return (
<div
role="alert"
className="border-danger-border bg-danger-soft text-danger rounded-box flex min-w-0 items-start gap-2.5 border px-3 py-2.5 text-[14px]"
>
<ShieldAlert aria-hidden className="mt-0.5 size-4 shrink-0" />
<p className="min-w-0">
<strong className="font-semibold">Hash-Kette beschädigt.</strong> Der Eintrag{" "}
<a href={ziel} className="font-mono underline underline-offset-2">
#{id}
</a>{" "}
stimmt nicht mit seinem Hash oder Vorgänger überein – ab hier ist das Protokoll nicht mehr belegbar.
Vergleichen Sie mit der Syslog-Kopie und sichern Sie die Datenbank.{wann && ` Geprüft${wann}.`}
</p>
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { clsx } from "clsx";
import { ChevronDown, History } from "lucide-react";
import { useState, useTransition } from "react";
import { Badge, type BadgeVariante } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { DiffBox } from "@/components/ui/DiffBox";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { aktionText, aktionTon, type AktionsTon } from "@/lib/protokoll-aktionen";
import { diffZeilen, ERGEBNIS_TEXT, protokollZeit } from "@/lib/protokoll-filter";
import { fingerprintKurz } from "@/lib/schluessel";
import type { ProtokollSeite, ProtokollZeile } from "@/server/queries/protokoll";
import { aeltereLadenAktion } from "@/server/actions/protokoll";
const TON_FARBE: Record<AktionsTon, string> = { ok: "text-ok", gefahr: "text-danger", neutral: "text-text" };
const ERGEBNIS_BADGE: Record<ProtokollZeile["ergebnis"], BadgeVariante> = {
ERFOLG: "neutral",
FEHLER: "gefahr",
VERWEIGERT: "gefahr",
};
/**
* Protokolleintraege als Tabelle (ab md) bzw. Karten (darunter), jede Zeile aufklappbar
* (#027). „Ältere laden“ haengt die naechste Seite an, statt die Liste zu ersetzen – wer
* eine Spur verfolgt, will das Neuere dabei nicht aus dem Blick verlieren.
*/
export function ProtokollListe({ seite, query }: { seite: ProtokollSeite; query: string }) {
const [zeilen, setZeilen] = useState(seite.zeilen);
const [weiterVor, setWeiterVor] = useState(seite.weiterVor);
const [offen, setOffen] = useState<ReadonlySet<string>>(new Set());
const [fehler, setFehler] = useState<string>();
const [laedt, starten] = useTransition();
const umschalten = (id: string) =>
setOffen((vorher) => {
const neu = new Set(vorher);
if (neu.has(id)) neu.delete(id);
else neu.add(id);
return neu;
});
const aeltereLaden = () => {
if (!weiterVor) return;
starten(async () => {
const antwort = await aeltereLadenAktion(query, weiterVor);
if ("fehler" in antwort) {
setFehler(antwort.fehler);
return;
}
setFehler(undefined);
setZeilen((vorher) => [...vorher, ...antwort.zeilen]);
setWeiterVor(antwort.weiterVor);
});
};
return (
<div className="flex min-w-0 flex-col gap-4">
{/* Unter md Karten statt der Tabelle: acht Spalten passen nicht auf 390 px. */}
<ul className="flex flex-col md:hidden" aria-label="Protokolleinträge">
{zeilen.map((z) => (
<li key={z.id} className="border-border border-b py-3 last:border-b-0">
<ZeileKompakt z={z} />
<details className="group mt-1">
<summary className="text-muted hover:text-text -mx-2 flex min-h-11 cursor-pointer list-none items-center gap-1.5 rounded-box px-2 text-[13px]">
<ChevronDown aria-hidden className="size-4 transition-transform group-open:rotate-180" />
Einzelheiten
</summary>
<Einzelheiten z={z} />
</details>
</li>
))}
</ul>
<Tabelle
className="hidden md:block"
beschriftung="Protokolleinträge"
spalten={[
{ titel: "Zeitpunkt" },
{ titel: "Aktion" },
{ titel: "Akteur" },
{ titel: "Host" },
{ titel: "Zielbenutzer" },
{ titel: "Fingerprint" },
{ titel: "Ergebnis" },
{ titel: "Einzelheiten", versteckt: true, className: "w-12" },
]}
>
{zeilen.map((z) => {
const istOffen = offen.has(z.id);
return <ZeilenPaar key={z.id} z={z} offen={istOffen} umschalten={() => umschalten(z.id)} />;
})}
</Tabelle>
{fehler && <p className="text-danger text-[13px]">{fehler}</p>}
{weiterVor && (
<div className="flex justify-center">
<Button onClick={aeltereLaden} disabled={laedt} icon={<History className="size-4" />}>
{laedt ? "Lädt …" : "Ältere laden"}
</Button>
</div>
)}
</div>
);
}
function AktionZelle({ z }: { z: ProtokollZeile }) {
return (
<span title={z.aktion} className={clsx("font-medium", TON_FARBE[aktionTon(z.aktion, z.ergebnis)])}>
{aktionText(z.aktion)}
</span>
);
}
function ErgebnisBadge({ ergebnis }: { ergebnis: ProtokollZeile["ergebnis"] }) {
return <Badge variante={ERGEBNIS_BADGE[ergebnis]}>{ERGEBNIS_TEXT[ergebnis]}</Badge>;
}
function ZeilenPaar({ z, offen, umschalten }: { z: ProtokollZeile; offen: boolean; umschalten: () => void }) {
const detailId = `eintrag-${z.id}-einzelheiten`;
return (
<>
<TabellenZeile className={clsx(offen && "border-b-0")}>
<TabellenZelle mono className="text-muted whitespace-nowrap">
{/* Anker fuer den Sprung aus der Kettenpruefung zur ersten fehlerhaften ID. */}
<span
id={`eintrag-${z.id}`}
className="target:text-danger target:outline-danger scroll-mt-24 target:outline target:outline-offset-4"
>
{protokollZeit(z.zeitpunkt)}
</span>
</TabellenZelle>
<TabellenZelle className="min-w-[12rem]">
<AktionZelle z={z} />
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">{z.akteurName}</TabellenZelle>
<TabellenZelle mono className="whitespace-nowrap">
{z.hostName ?? <Leer />}
</TabellenZelle>
<TabellenZelle mono className="whitespace-nowrap">
{z.zielbenutzer ?? <Leer />}
</TabellenZelle>
<TabellenZelle mono className="text-muted whitespace-nowrap">
{z.fingerprint ? <span title={z.fingerprint}>{fingerprintKurz(z.fingerprint)}</span> : <Leer />}
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<ErgebnisBadge ergebnis={z.ergebnis} />
</TabellenZelle>
<TabellenZelle rechts className="w-12">
<button
type="button"
onClick={umschalten}
aria-expanded={offen}
aria-controls={detailId}
aria-label={`Einzelheiten zu Eintrag ${z.id}`}
className="text-muted hover:text-text hover:bg-raised rounded-feld -my-2 inline-flex size-11 items-center justify-center transition-colors"
>
<ChevronDown aria-hidden className={clsx("size-4 transition-transform", offen && "rotate-180")} />
</button>
</TabellenZelle>
</TabellenZeile>
{offen && (
<tr id={detailId} className="border-border border-b last:border-b-0">
<td colSpan={8} className="pb-4">
<Einzelheiten z={z} />
</td>
</tr>
)}
</>
);
}
function Leer() {
return <span className="text-muted">–</span>;
}
/** Kopf einer Karte in der mobilen Ansicht. */
function ZeileKompakt({ z }: { z: ProtokollZeile }) {
return (
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<span className="text-muted font-mono text-[12.5px]">{protokollZeit(z.zeitpunkt)}</span>
<ErgebnisBadge ergebnis={z.ergebnis} />
</div>
<span className="text-[14.5px] leading-snug">
<AktionZelle z={z} />
</span>
<span className="text-muted text-[13px] break-words">{z.akteurName}</span>
{(z.hostName || z.zielbenutzer) && (
<span className="text-text font-mono text-[12.5px] break-all">
{z.zielbenutzer ? `${z.zielbenutzer}@` : ""}
{z.hostName ?? "–"}
</span>
)}
{z.fingerprint && (
<span className="text-muted font-mono text-[12.5px] break-all">{fingerprintKurz(z.fingerprint)}</span>
)}
</div>
);
}
function Einzelheiten({ z }: { z: ProtokollZeile }) {
return (
<div className="bg-sidebar border-border rounded-box flex min-w-0 flex-col gap-4 border p-4">
<dl className="grid min-w-0 gap-x-4 gap-y-2 text-[13px] sm:grid-cols-[9rem_minmax(0,1fr)]">
<dt className="text-muted">ID</dt>
<dd className="font-mono">{z.id}</dd>
<dt className="text-muted">Aktion (technisch)</dt>
<dd className="font-mono break-all">{z.aktion}</dd>
{z.fingerprint && (
<>
<dt className="text-muted">Fingerprint</dt>
<dd className="font-mono break-all">{z.fingerprint}</dd>
</>
)}
<dt className="text-muted">Hash</dt>
<dd className="font-mono break-all" data-feld="hash">
{z.hash}
</dd>
<dt className="text-muted">Vorgänger-Hash</dt>
<dd className="font-mono break-all">{z.vorgaengerHash || "– (erster Eintrag)"}</dd>
</dl>
{z.diff && (
<section className="flex min-w-0 flex-col gap-2" aria-label="Diff">
<h3 className="text-muted text-[13px] font-medium">Diff</h3>
<DiffBox zeilen={diffZeilen(z.diff)} />
</section>
)}
{z.details && (
<section className="flex min-w-0 flex-col gap-2" aria-label="Details">
<h3 className="text-muted text-[13px] font-medium">Details</h3>
<dl className="border-border bg-bg rounded-box grid min-w-0 gap-x-4 gap-y-1.5 border p-3 text-[12.5px] sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)]">
{z.details.map((d) => (
<div key={d.name} className="contents">
<dt className="text-muted font-mono break-all">{d.name}</dt>
<dd className="text-text mb-1.5 font-mono break-all whitespace-pre-wrap sm:mb-0">{d.wert}</dd>
</div>
))}
</dl>
</section>
)}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Lesbare Beschriftung der technischen Protokoll-Aktionen (#027).
*
* Das Protokoll speichert stabile Schluessel wie `authorized_keys.geschrieben`, damit sich
* Auswertungen (CSV, Syslog, SIEM) nicht an Oberflaechentexten aufhaengen. Die Ansicht
* uebersetzt sie hier. `tests/protokoll-aktionen.test.ts` sucht jede `aktion: "…"` im
* Quellcode und verlangt einen Eintrag – eine neue Aktion ohne Text faellt so sofort auf.
*
* `aendert`: Die Aktion veraendert etwas (Datei geschrieben, Schluessel erzeugt …) und wird bei
* Erfolg in --ok gezeigt. Lesende oder rein informative Aktionen (Anmeldung, Abruf, Export)
* bleiben neutral, damit das Grün die Stellen markiert, an denen sich etwas getan hat.
*/
export const AKTIONSGRUPPEN = {
anmeldung: "Anmeldung",
hostkey: "Host-Key",
ausrollen: "Ausrollen",
schluessel: "Schlüssel",
widerruf: "Widerruf/Rotation",
zielkonto: "Zielkonto",
abgleich: "Abgleich",
hosts: "Hosts",
stammdaten: "Personen und Vorlagen",
protokoll: "Protokoll",
} as const;
export type Aktionsgruppe = keyof typeof AKTIONSGRUPPEN;
type Beschreibung = { readonly text: string; readonly gruppe: Aktionsgruppe; readonly aendert: boolean };
const a = (text: string, gruppe: Aktionsgruppe, aendert = true): Beschreibung => ({ text, gruppe, aendert });
export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
// Anmeldung und Bestaetigung (#004)
"anmeldung.oidc": a("Anmeldung (OIDC)", "anmeldung", false),
"anmeldung.oidc.erstmals": a("Erste Anmeldung (OIDC)", "anmeldung"),
"anmeldung.verweigert": a("Anmeldung verweigert", "anmeldung", false),
"anmeldung.notfall": a("Anmeldung Notfall-Admin", "anmeldung", false),
"anmeldung.notfall.fehlgeschlagen": a("Notfall-Anmeldung fehlgeschlagen", "anmeldung", false),
"anmeldung.notfall.gesperrt": a("Notfall-Anmeldung gesperrt", "anmeldung", false),
"anmeldung.notfall.totp_falsch": a("Notfall-Anmeldung: TOTP falsch", "anmeldung", false),
"bestaetigung.oidc": a("Anmeldung bestätigt (Re-Auth)", "anmeldung", false),
"notfall.totp_eingerichtet": a("TOTP für Notfall-Admin eingerichtet", "anmeldung"),
// Host-Keys (#009)
"hostkey.abgerufen": a("Host-Key abgerufen", "hostkey", false),
"hostkey.bestaetigt": a("Host-Key bestätigt", "hostkey"),
"hostkey.ersetzt": a("Host-Key ersetzt", "hostkey"),
"hostkey.abgelehnt": a("Host-Key abgelehnt", "hostkey"),
"hostkey.abweichung": a("Host-Key weicht ab – Verbindung abgebrochen", "hostkey", false),
// Ausrollen (#016, #017)
"authorized_keys.geschrieben": a("authorized_keys geschrieben", "ausrollen"),
"privatekey.ausgerollt": a("Private Key ausgerollt", "ausrollen"),
"verwendung.public.angelegt": a("Public Key zugewiesen", "ausrollen"),
"verwendung.public.entfernen": a("Public-Key-Zuweisung zum Entfernen vorgemerkt", "ausrollen"),
"verwendung.private.angelegt": a("Private Key zugewiesen", "ausrollen"),
"verwendung.private.entfernen": a("Private-Key-Zuweisung zum Entfernen vorgemerkt", "ausrollen"),
// Schluessel (#012, #013, #014)
"schluessel.erzeugt": a("Schlüssel erzeugt", "schluessel"),
"schluessel.importiert": a("Schlüssel importiert", "schluessel"),
"schluessel.uebernommen": a("Schlüssel vom Host übernommen", "schluessel"),
"schluessel.selbst_hinterlegt": a("Eigenen Public Key hinterlegt", "schluessel"),
"schluessel.selbst_entfernt": a("Eigenen Public Key entfernt", "schluessel"),
"schluessel.geaendert": a("Schlüssel geändert", "schluessel"),
"privatekey.angefordert": a("Private Key angefordert", "schluessel", false),
"privatekey.angezeigt": a("Private Key angezeigt", "schluessel", false),
"privatekey.heruntergeladen": a("Private Key heruntergeladen", "schluessel", false),
"privatekey.geloescht": a("Private Key gelöscht", "schluessel"),
"deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"),
// Widerruf und Rotation (#023, #024)
"schluessel.widerruf.eingeleitet": a("Widerruf eingeleitet", "widerruf"),
"schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"),
"schluessel.rotiert": a("Schlüssel rotiert", "widerruf"),
"schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"),
// Zielkonten (#018)
"zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"),
"zielkonto.geaendert": a("Zielkonto geändert", "zielkonto"),
"zielkonto.entfernt": a("Zielkonto entfernt", "zielkonto"),
// Abgleich und Uebernahme (#021, #022)
abgleich: a("Abgleich durchgeführt", "abgleich", false),
"uebernahme.verwalten": a("Fremden Key übernommen und verwaltet", "abgleich"),
"uebernahme.bekannt": a("Fremden Key als bekannt aufgenommen", "abgleich"),
// Hosts, Gruppen, Kunden (#008, #010)
"host.angelegt": a("Host angelegt", "hosts"),
"host.geaendert": a("Host geändert", "hosts"),
"host.archiviert": a("Host archiviert", "hosts"),
"host.bootstrap.angefordert": a("Einrichtung angefordert", "hosts", false),
"host.bootstrap": a("Host eingerichtet (Bootstrap)", "hosts"),
"host.bootstrap.geprueft": a("Einrichtung geprüft", "hosts"),
"hostgruppe.angelegt": a("Hostgruppe angelegt", "hosts"),
"hostgruppe.geaendert": a("Hostgruppe geändert", "hosts"),
"kunde.angelegt": a("Kunde angelegt", "hosts"),
verbindungstest: a("Verbindung geprüft", "hosts", false),
// Personen und Vorlagen (#013, #019)
"person.angelegt": a("Person angelegt", "stammdaten"),
"person.geaendert": a("Person geändert", "stammdaten"),
"person.archiviert": a("Person archiviert", "stammdaten"),
"person.verknuepft": a("Person mit Konto verknüpft", "stammdaten"),
"vorlage.angelegt": a("Vorlage angelegt", "stammdaten"),
"vorlage.geaendert": a("Vorlage geändert", "stammdaten"),
"vorlage.archiviert": a("Vorlage archiviert", "stammdaten"),
// Protokoll selbst (#027)
"protokoll.exportiert": a("Protokoll exportiert (CSV)", "protokoll", false),
"protokoll.kette_beschaedigt": a("Hash-Kette beschädigt gefunden", "protokoll", false),
};
/** Text zur Aktion; unbekannte (etwa aus aelteren Versionen) bleiben technisch lesbar. */
export function aktionText(aktion: string): string {
return AKTIONEN[aktion]?.text ?? aktion;
}
/** Alle bekannten Aktionen einer Gruppe – Grundlage fuer den Filter `aktion IN (…)`. */
export function aktionenDerGruppe(gruppe: Aktionsgruppe): string[] {
return Object.entries(AKTIONEN)
.filter(([, b]) => b.gruppe === gruppe)
.map(([aktion]) => aktion);
}
export type AktionsTon = "ok" | "gefahr" | "neutral";
/** Farbe in der Liste: Fehlschlag und Verweigerung immer --danger, Aenderungen --ok. */
export function aktionTon(aktion: string, ergebnis: "ERFOLG" | "FEHLER" | "VERWEIGERT"): AktionsTon {
if (ergebnis !== "ERFOLG") return "gefahr";
return AKTIONEN[aktion]?.aendert ? "ok" : "neutral";
}
export function istAktionsgruppe(wert: string): wert is Aktionsgruppe {
return Object.hasOwn(AKTIONSGRUPPEN, wert);
}
+211
View File
@@ -0,0 +1,211 @@
import { istAktionsgruppe, type Aktionsgruppe } from "@/lib/protokoll-aktionen";
import { ZEITZONE } from "@/lib/format";
/**
* Filter der Protokollansicht (#027). Framework-frei: Seite, CSV-Route und Server Action
* lesen dieselben GET-Parameter, damit der Export genau den Ausschnitt der Seite liefert.
*/
export const PROTOKOLL_ERGEBNISSE = ["ERFOLG", "FEHLER", "VERWEIGERT"] as const;
export type ProtokollErgebnisWert = (typeof PROTOKOLL_ERGEBNISSE)[number];
export const ERGEBNIS_TEXT: Record<ProtokollErgebnisWert, string> = {
ERFOLG: "Erfolg",
FEHLER: "Fehler",
VERWEIGERT: "Verweigert",
};
export type ProtokollFilter = {
/** Tag im Format JJJJ-MM-TT (Europe/Berlin), einschliesslich. */
readonly von: string;
readonly bis: string;
readonly gruppe: Aktionsgruppe | "";
readonly akteur: string;
readonly host: string;
readonly ergebnis: ProtokollErgebnisWert | "";
readonly q: string;
};
export const LEERER_FILTER: ProtokollFilter = {
von: "",
bis: "",
gruppe: "",
akteur: "",
host: "",
ergebnis: "",
q: "",
};
const TAG = /^\d{4}-\d{2}-\d{2}$/;
function gueltigerTag(wert: string): string {
if (!TAG.test(wert)) return "";
return Number.isNaN(new Date(`${wert}T00:00:00Z`).getTime()) ? "" : wert;
}
export function protokollFilterAus(
parameter: Record<string, string | string[] | undefined>,
): ProtokollFilter {
const einzeln = (name: string) => {
const wert = parameter[name];
return ((Array.isArray(wert) ? wert[0] : wert) ?? "").trim().slice(0, 200);
};
const gruppe = einzeln("gruppe");
const ergebnis = einzeln("ergebnis");
return {
von: gueltigerTag(einzeln("von")),
bis: gueltigerTag(einzeln("bis")),
gruppe: istAktionsgruppe(gruppe) ? gruppe : "",
akteur: einzeln("akteur"),
host: einzeln("host"),
ergebnis: (PROTOKOLL_ERGEBNISSE as readonly string[]).includes(ergebnis)
? (ergebnis as ProtokollErgebnisWert)
: "",
q: einzeln("q"),
};
}
export function protokollFilterAktiv(filter: ProtokollFilter): boolean {
return Object.values(filter).some(Boolean);
}
/** Dieselben Filter als Query-String (CSV-Link, „ältere laden“, Anker auf eine ID). */
export function protokollFilterQuery(filter: ProtokollFilter, extra: Record<string, string> = {}): string {
const p = new URLSearchParams();
for (const [name, wert] of Object.entries(filter)) if (wert) p.set(name, wert);
for (const [name, wert] of Object.entries(extra)) p.set(name, wert);
const text = p.toString();
return text ? `?${text}` : "";
}
/** Cursor „älter als ID“; nur positive Ganzzahlen, sonst keiner. */
export function cursorAus(wert: string | string[] | undefined): bigint | undefined {
const text = Array.isArray(wert) ? wert[0] : wert;
if (!text || !/^\d{1,19}$/.test(text)) return undefined;
const zahl = BigInt(text);
return zahl > 0n ? zahl : undefined;
}
/** Abstand Europe/Berlin zu UTC in Millisekunden zu einem Zeitpunkt (Sommer-/Winterzeit). */
function berlinVersatz(zeitpunkt: Date): number {
const teile = new Intl.DateTimeFormat("en-US", {
timeZone: ZEITZONE,
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).formatToParts(zeitpunkt);
const wert = (typ: string) => Number(teile.find((t) => t.type === typ)?.value ?? 0);
const alsUtc = Date.UTC(
wert("year"),
wert("month") - 1,
wert("day"),
wert("hour"),
wert("minute"),
wert("second"),
);
return alsUtc - Math.floor(zeitpunkt.getTime() / 1000) * 1000;
}
/**
* Mitternacht des Tages in Berlin als Zeitpunkt. Die Oberflaeche zeigt Berliner Zeit (#002),
* also muss „von 12.10.“ auch Berliner Mitternacht meinen – nicht UTC.
*/
export function berlinTagesbeginn(tag: string): Date {
const utcMitternacht = new Date(`${tag}T00:00:00Z`);
const erst = new Date(utcMitternacht.getTime() - berlinVersatz(utcMitternacht));
// Zweiter Schritt fuer Tage, an denen die Uhr umgestellt wird.
return new Date(utcMitternacht.getTime() - berlinVersatz(erst));
}
/** Zeitraum als halboffenes Intervall [ab, vor) – „bis“ schliesst den ganzen Tag ein. */
export function zeitraum(filter: Pick<ProtokollFilter, "von" | "bis">): { ab?: Date; vor?: Date } {
const ergebnis: { ab?: Date; vor?: Date } = {};
if (filter.von) ergebnis.ab = berlinTagesbeginn(filter.von);
if (filter.bis) {
const folgetag = new Date(`${filter.bis}T00:00:00Z`);
folgetag.setUTCDate(folgetag.getUTCDate() + 1);
ergebnis.vor = berlinTagesbeginn(folgetag.toISOString().slice(0, 10));
}
return ergebnis;
}
/** „12.10.2026 08:42:17“ – Sekunden gehoeren ins Protokoll, Komma nicht (#027). */
const ZEITSTEMPEL = new Intl.DateTimeFormat("de-DE", {
timeZone: ZEITZONE,
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
export function protokollZeit(wert: Date | string): string {
return ZEITSTEMPEL.format(new Date(wert)).replace(",", "");
}
export type ProtokollDiffZeile = { art: "hinzu" | "entfernt" | "kontext"; text: string };
/**
* Der gespeicherte Diff ist Text mit `+ `/`- ` je Zeile (#016); Private-Key-Ausrollen schreibt
* nur „anlegen ~/.ssh/…“ – solche Zeilen bleiben Kontext.
*/
export function diffZeilen(diff: string): ProtokollDiffZeile[] {
return diff.split("\n").map((zeile) => {
if (zeile.startsWith("+")) return { art: "hinzu", text: zeile.replace(/^\+ ?/, "") };
if (zeile.startsWith("-")) return { art: "entfernt", text: zeile.replace(/^- ?/, "") };
return { art: "kontext", text: zeile };
});
}
/** Spalten des CSV-Exports (#027): technisch, damit Auswertungen stabil bleiben. */
export const PROTOKOLL_CSV_KOPF = [
"id",
"zeitpunkt",
"akteur",
"aktion",
"ergebnis",
"host",
"zielbenutzer",
"fingerprint",
"diff",
"details",
"vorgaengerHash",
"hash",
] as const;
export type ProtokollCsvQuelle = {
readonly id: bigint | string;
readonly zeitpunkt: Date;
readonly akteurName: string;
readonly aktion: string;
readonly ergebnis: string;
readonly hostName: string | null;
readonly zielbenutzer: string | null;
readonly fingerprint: string | null;
readonly diff: string | null;
readonly details: unknown;
readonly vorgaengerHash: string;
readonly hash: string;
};
export function protokollCsvWerte(z: ProtokollCsvQuelle): (string | null)[] {
return [
String(z.id),
z.zeitpunkt.toISOString(),
z.akteurName,
z.aktion,
z.ergebnis,
z.hostName,
z.zielbenutzer,
z.fingerprint,
z.diff,
z.details == null ? null : JSON.stringify(z.details),
z.vorgaengerHash,
z.hash,
];
}
+255
View File
@@ -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;
},
};
}
+51
View File
@@ -0,0 +1,51 @@
"use server";
import { cursorAus, protokollFilterAus } from "@/lib/protokoll-filter";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { protokollSeite, type ProtokollSeite } from "@/server/queries/protokoll";
import { kettePruefenZwischengespeichert } from "@/server/services/protokoll";
/**
* Protokollansicht (#027): nur lesende Aktionen. Bewusst gibt es hier nichts, was einen
* Eintrag aendert oder loescht – das verbietet ohnehin der Trigger, und
* `tests/protokoll-ansicht.test.ts` haelt fest, dass auch kein Code es versucht.
*/
export type KettenAntwort = {
readonly fehlerhafteId?: string | null;
readonly eintraege?: number;
readonly geprueftAm?: string;
readonly fehler?: string;
};
export async function kettePruefenAktion(): Promise<KettenAntwort> {
try {
const { benutzer } = await rechtErforderlich("lesen");
const ergebnis = await kettePruefenZwischengespeichert({ id: benutzer.id, name: benutzer.name });
return {
fehlerhafteId: ergebnis.fehlerhafteId,
eintraege: ergebnis.eintraege,
geprueftAm: ergebnis.geprueftAm.toISOString(),
};
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
throw fehler;
}
}
/** „Ältere laden“: dieselben GET-Filter wie die Seite, Cursor ist die kleinste gezeigte ID. */
export async function aeltereLadenAktion(
query: string,
vor: string,
): Promise<ProtokollSeite | { fehler: string }> {
try {
await rechtErforderlich("lesen");
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
throw fehler;
}
const cursor = cursorAus(String(vor));
if (cursor === undefined) return { zeilen: [], weiterVor: null };
const filter = protokollFilterAus(Object.fromEntries(new URLSearchParams(String(query).slice(0, 2000))));
return protokollSeite(filter, cursor);
}
+156
View File
@@ -0,0 +1,156 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { aktionenDerGruppe } from "@/lib/protokoll-aktionen";
import { zeitraum, type ProtokollFilter } from "@/lib/protokoll-filter";
/**
* Lesemodell des Protokolls (#027). Jede Rolle darf das ganze Protokoll lesen (PLAN §4);
* die Rechtepruefung machen Seite, Action und Route selbst.
*
* Geblaettert wird ueber die ID („älter als“), nicht ueber OFFSET: das Protokoll waechst
* waehrend man liest, und OFFSET wuerde dann Zeilen doppelt zeigen oder ueberspringen –
* ausserdem muesste die Datenbank bei OFFSET 10 000 erst 10 000 Zeilen lesen und verwerfen.
*/
export const SEITENGROESSE = 50;
export type ProtokollZeile = {
readonly id: string;
readonly zeitpunkt: string;
readonly akteurName: string;
readonly aktion: string;
readonly ergebnis: "ERFOLG" | "FEHLER" | "VERWEIGERT";
readonly hostName: string | null;
readonly zielbenutzer: string | null;
readonly fingerprint: string | null;
readonly diff: string | null;
/** Bereits beim Schreiben geschwaerzt (#005); oberste Ebene als Liste, Werte formatiert. */
readonly details: { readonly name: string; readonly wert: string }[] | null;
readonly vorgaengerHash: string;
readonly hash: string;
};
const AUSWAHL = {
id: true,
zeitpunkt: true,
akteurName: true,
aktion: true,
ergebnis: true,
hostName: true,
zielbenutzer: true,
fingerprint: true,
diff: true,
details: true,
vorgaengerHash: true,
hash: true,
} satisfies Prisma.ProtokollSelect;
type Rohzeile = Prisma.ProtokollGetPayload<{ select: typeof AUSWAHL }>;
export function protokollWhere(filter: ProtokollFilter, vor?: bigint): Prisma.ProtokollWhereInput {
const und: Prisma.ProtokollWhereInput[] = [];
const { ab, vor: bis } = zeitraum(filter);
if (ab || bis) und.push({ zeitpunkt: { ...(ab ? { gte: ab } : {}), ...(bis ? { lt: bis } : {}) } });
if (filter.gruppe) und.push({ aktion: { in: aktionenDerGruppe(filter.gruppe) } });
if (filter.akteur) und.push({ akteurName: filter.akteur });
if (filter.host) und.push({ hostName: { contains: filter.host, mode: "insensitive" } });
if (filter.ergebnis) und.push({ ergebnis: filter.ergebnis });
if (filter.q) {
const enthaelt = { contains: filter.q, mode: "insensitive" } as const;
und.push({ OR: [{ fingerprint: enthaelt }, { hostName: enthaelt }, { zielbenutzer: enthaelt }] });
}
if (vor !== undefined) und.push({ id: { lt: vor } });
return und.length ? { AND: und } : {};
}
/** Texte bleiben Texte; Zahlen, Listen und Objekte als eingerueckte JSON. */
function detailsListe(details: Prisma.JsonValue): ProtokollZeile["details"] {
if (details == null) return null;
const alsText = (wert: unknown) => (typeof wert === "string" ? wert : JSON.stringify(wert, null, 2));
if (typeof details !== "object" || Array.isArray(details))
return [{ name: "Wert", wert: alsText(details) }];
const eintraege = Object.entries(details).map(([name, wert]) => ({ name, wert: alsText(wert) }));
return eintraege.length ? eintraege : null;
}
function zeileAus(z: Rohzeile): ProtokollZeile {
return {
id: String(z.id),
zeitpunkt: z.zeitpunkt.toISOString(),
akteurName: z.akteurName,
aktion: z.aktion,
ergebnis: z.ergebnis,
hostName: z.hostName,
zielbenutzer: z.zielbenutzer,
fingerprint: z.fingerprint,
diff: z.diff,
details: detailsListe(z.details),
vorgaengerHash: z.vorgaengerHash,
hash: z.hash,
};
}
export type ProtokollSeite = {
readonly zeilen: ProtokollZeile[];
/** ID fuer „ältere laden“; fehlt, wenn es nichts Aelteres mehr gibt. */
readonly weiterVor: string | null;
};
/** Eine Seite, neueste zuerst; `vor` ist die kleinste ID der vorigen Seite. */
export async function protokollSeite(filter: ProtokollFilter, vor?: bigint): Promise<ProtokollSeite> {
const roh = await prisma.protokoll.findMany({
where: protokollWhere(filter, vor),
orderBy: { id: "desc" },
take: SEITENGROESSE + 1,
select: AUSWAHL,
});
const mehr = roh.length > SEITENGROESSE;
const zeilen = roh.slice(0, SEITENGROESSE).map(zeileAus);
return { zeilen, weiterVor: mehr ? (zeilen.at(-1)?.id ?? null) : null };
}
/** Kopfzeile der Seite: Gesamtzahl und Treffer des Filters. */
export async function protokollZaehlen(
filter: ProtokollFilter,
): Promise<{ gesamt: number; treffer: number }> {
const [gesamt, treffer] = await Promise.all([
prisma.protokoll.count(),
prisma.protokoll.count({ where: protokollWhere(filter) }),
]);
return { gesamt, treffer };
}
/** Namen fuer den Akteur-Filter – haeufigste zuerst, begrenzt, damit die Liste handlich bleibt. */
export async function protokollAkteure(): Promise<string[]> {
const gruppen = await prisma.protokoll.groupBy({
by: ["akteurName"],
_count: { _all: true },
orderBy: { _count: { akteurName: "desc" } },
take: 100,
});
return gruppen.map((g) => g.akteurName).sort((a, b) => a.localeCompare(b, "de"));
}
/**
* Alle Treffer in Stapeln, neueste zuerst – fuer den CSV-Export. Liest je Stapel nur
* `stapel` Zeilen, damit auch ein grosses Protokoll nie ganz im Speicher liegt.
*/
export async function* protokollStapelweise(
filter: ProtokollFilter,
stapel = 500,
): AsyncGenerator<Rohzeile[], void, undefined> {
let vor: bigint | undefined;
for (;;) {
const zeilen = await prisma.protokoll.findMany({
where: protokollWhere(filter, vor),
orderBy: { id: "desc" },
take: stapel,
select: AUSWAHL,
});
if (zeilen.length === 0) return;
yield zeilen;
if (zeilen.length < stapel) return;
vor = zeilen.at(-1)!.id;
}
}
export type { Rohzeile as ProtokollRohzeile };
+6 -1
View File
@@ -1,5 +1,6 @@
import type { AbweichungsArt } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { aktionText } from "@/lib/protokoll-aktionen";
/**
* Lesemodell der Übersicht (#026). Alle Zahlen werden berechnet, nichts ist hinterlegt –
@@ -190,7 +191,11 @@ export async function uebersicht(jetzt = new Date()) {
zeit: a.zeitpunkt,
art: aktionEinordnen(a.aktion, a.ergebnis),
aktion: a.aktion,
beschreibung: [a.aktion, a.zielbenutzer && a.hostName ? `${a.zielbenutzer}@${a.hostName}` : a.hostName]
// Lesbarer Text aus dem Audit-Log (#027) statt des technischen Aktionsnamens.
beschreibung: [
aktionText(a.aktion),
a.zielbenutzer && a.hostName ? `${a.zielbenutzer}@${a.hostName}` : a.hostName,
]
.filter(Boolean)
.join(" · "),
benutzer: a.akteurName,
+109 -2
View File
@@ -2,6 +2,7 @@ import type { Prisma } from "@/generated/prisma/client";
import type { ProtokollErgebnis } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { schwaerzen } from "@/lib/protokoll-schwaerzen";
import { syslogKonfigAus, syslogSender, type SyslogKonfig, type SyslogSender } from "@/lib/syslog";
/**
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
@@ -26,7 +27,7 @@ export async function protokollieren(
client: Prisma.TransactionClient = prisma,
): Promise<void> {
const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur;
await client.protokoll.create({
const zeile = await client.protokoll.create({
data: {
akteurId: akteur.id,
akteurName: akteur.name,
@@ -40,13 +41,119 @@ export async function protokollieren(
diff: eintrag.diff ? (schwaerzen(eintrag.diff) as string) : null,
details: eintrag.details ? (schwaerzen(eintrag.details) as Prisma.InputJsonValue) : undefined,
},
select: { id: true, zeitpunkt: true, hash: true },
});
// Erst nach dem erfolgreichen Schreiben, ohne zu warten (#027): Syslog ist die Kopie,
// die Datenbank die Quelle. Innerhalb einer Transaktion kann ein spaeterer Rollback eine
// Syslog-Zeile ohne Gegenstueck hinterlassen – deren Hash fehlt dann in der Kette und faellt
// beim Vergleich auf; umgekehrt (Eintrag ohne Syslog-Zeile) waere schlimmer.
syslogWeitergeben({
id: zeile.id,
zeitpunkt: zeile.zeitpunkt,
akteur: akteur.name,
aktion: eintrag.aktion,
ergebnis: eintrag.ergebnis,
hostName: eintrag.hostName ?? null,
zielbenutzer: eintrag.zielbenutzer ?? null,
fingerprint: eintrag.fingerprint ?? null,
hash: zeile.hash,
});
}
/** Prueft die Hash-Kette in der Datenbank; liefert die erste fehlerhafte ID oder null. */
let syslog: { konfig: SyslogKonfig; sender: SyslogSender } | null | undefined;
/** Konfiguration einmal je Prozess lesen; Web und Worker haben jeder einen Sender. */
export function syslogKonfig(): SyslogKonfig | null {
if (syslog === undefined) {
const konfig = syslogKonfigAus(process.env);
syslog = konfig ? { konfig, sender: syslogSender(konfig) } : null;
}
return syslog?.konfig ?? null;
}
/** Nur fuer Tests: Umgebung neu lesen. */
export function syslogZuruecksetzen(): void {
syslog?.sender.schliessen();
syslog = undefined;
}
function syslogWeitergeben(eintrag: Parameters<SyslogSender["senden"]>[0]): void {
try {
if (!syslogKonfig()) return;
syslog?.sender.senden(eintrag);
} catch {
// senden() wirft nicht; das hier faengt nur eine kaputte Konfiguration ab.
}
}
/**
* Prueft die Hash-Kette in der Datenbank; liefert die erste fehlerhafte ID oder null.
* Liest jede Zeile – fuer die Oberflaeche `ketteGeprueft()` mit Zwischenspeicher nehmen.
*/
export async function protokollKettePruefen(): Promise<bigint | null> {
const [zeile] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
return zeile?.ergebnis ?? null;
}
export type Kettenpruefung = {
readonly fehlerhafteId: string | null;
readonly eintraege: number;
readonly geprueftAm: Date;
};
/** Wie lange ein Pruefergebnis gilt, bevor „Kette prüfen“ wieder rechnet (#027). */
export const KETTE_ZWISCHENSPEICHER_MS = 5 * 60 * 1000;
let letztePruefung: Kettenpruefung | null = null;
let laufendePruefung: Promise<Kettenpruefung> | null = null;
/**
* Die Pruefung rechnet SHA-256 ueber jede Zeile und waechst mit dem Protokoll; deshalb laeuft
* sie nie beim blossen Seitenaufruf, sondern auf Knopfdruck, und das Ergebnis gilt 5 Minuten
* je Prozess. Gleichzeitige Klicks teilen sich eine laufende Pruefung (#027).
*/
export function ketteZwischengespeichert(jetzt: Date = new Date()): Kettenpruefung | null {
if (!letztePruefung) return null;
return jetzt.getTime() - letztePruefung.geprueftAm.getTime() < KETTE_ZWISCHENSPEICHER_MS
? letztePruefung
: null;
}
export async function kettePruefenZwischengespeichert(
akteur: ProtokollEintrag["akteur"],
): Promise<Kettenpruefung> {
const vorhanden = ketteZwischengespeichert();
if (vorhanden) return vorhanden;
laufendePruefung ??= (async () => {
try {
const eintraege = await prisma.protokoll.count();
const fehlerhaft = await protokollKettePruefen();
const ergebnis = {
fehlerhafteId: fehlerhaft === null ? null : String(fehlerhaft),
eintraege,
geprueftAm: new Date(),
};
if (fehlerhaft !== null) {
// Ein Bruch ist ein Sicherheitsereignis und soll auch beim Syslog ankommen.
await protokollieren({
akteur,
aktion: "protokoll.kette_beschaedigt",
ergebnis: "FEHLER",
details: { fehlerhafteId: String(fehlerhaft) },
});
}
letztePruefung = ergebnis;
return ergebnis;
} finally {
laufendePruefung = null;
}
})();
return laufendePruefung;
}
/** Nur fuer Tests. */
export function ketteZwischenspeicherLeeren(): void {
letztePruefung = null;
}
+52
View File
@@ -0,0 +1,52 @@
import { readFile } from "node:fs/promises";
import { expect, test } from "@playwright/test";
import { anmelden } from "./hilfen";
/**
* Audit-Log (#027) gegen den gebauten Stand: Liste, Filter, Aufklappen, Kettenpruefung und
* CSV-Export. Die Anmeldung selbst erzeugt den ersten Eintrag – ohne SSH-Testziele lauffaehig.
*/
test("Audit-Log zeigt, filtert, prüft und exportiert", async ({ page }) => {
await anmelden(page);
await page.goto("/protokoll");
await expect(page.getByRole("heading", { name: "Audit-Log" })).toBeVisible();
const tabelle = page.getByRole("table", { name: "Protokolleinträge" });
await expect(tabelle.getByRole("row").nth(1)).toBeVisible();
// Filter nach Aktionsgruppe: nur noch Anmeldungen.
await page.getByLabel("Aktion").selectOption({ label: "Anmeldung" });
await page.getByRole("button", { name: "Filtern" }).click();
await expect(page).toHaveURL(/gruppe=anmeldung/);
const zeilen = tabelle.locator("tbody > tr");
await expect(zeilen.first()).toBeVisible();
const aktionen = await zeilen.locator("td:nth-child(2)").allInnerTexts();
expect(aktionen.length).toBeGreaterThan(0);
for (const aktion of aktionen) expect(aktion).toMatch(/Anmeld|TOTP/);
// Zeile aufklappen: Hash und Vorgaenger-Hash erscheinen.
await tabelle
.getByRole("button", { name: /Einzelheiten zu Eintrag/ })
.first()
.click();
await expect(tabelle.locator('[data-feld="hash"]').first()).toHaveText(/^[0-9a-f]{64}$/);
// Kettenpruefung auf Knopfdruck.
await page.getByRole("button", { name: "Kette prüfen" }).click();
await expect(page.getByText("Hash-Kette unversehrt")).toBeVisible({ timeout: 30_000 });
// CSV mit denselben Filtern; der Export landet selbst im Protokoll.
const [download] = await Promise.all([
page.waitForEvent("download"),
page.getByRole("link", { name: "CSV exportieren" }).click(),
]);
expect(download.suggestedFilename()).toMatch(/^sshkv-protokoll-\d{4}-\d{2}-\d{2}\.csv$/);
const text = (await readFile((await download.path())!, "utf8")).replace(/^/, "");
expect(text.split("\r\n")[0]).toBe(
'"id";"zeitpunkt";"akteur";"aktion";"ergebnis";"host";"zielbenutzer";"fingerprint";"diff";"details";"vorgaengerHash";"hash"',
);
expect(text).toContain("anmeldung.");
await page.goto("/protokoll?gruppe=protokoll");
await expect(tabelle.getByText("Protokoll exportiert (CSV)").first()).toBeVisible();
});
+75
View File
@@ -0,0 +1,75 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { AKTIONEN, AKTIONSGRUPPEN, aktionenDerGruppe, aktionText, aktionTon } from "@/lib/protokoll-aktionen";
/**
* Jede Protokoll-Aktion im Quellcode hat einen lesbaren Text (#027). Gesucht wird in allen
* Dateien, die `protokollieren(` rufen, nach `aktion: …`/`aktion = …` (auch in Ternaries), und
* ueberall nach Literalen, die mit einem bekannten Praefix beginnen (`"schluessel.uebernommen"`
* wird z. B. als Argument durchgereicht).
*/
function dateien(verzeichnis: string): string[] {
return readdirSync(verzeichnis).flatMap((name) => {
const pfad = path.join(verzeichnis, name);
if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
return /\.tsx?$/.test(name) ? [pfad] : [];
});
}
const QUELLEN = dateien("src").map((datei) => ({ datei, inhalt: readFileSync(datei, "utf8") }));
const LITERAL = /"([^"\n]+)"/g;
function aktionenImCode(): Map<string, string> {
const gefunden = new Map<string, string>();
const praefixe = new Set(Object.keys(AKTIONEN).map((a) => a.split(".")[0]!));
for (const { datei, inhalt } of QUELLEN) {
if (inhalt.includes("protokollieren(")) {
for (const treffer of inhalt.matchAll(/\baktion\s*[:=]\s*([^,\n]+)/g)) {
// Bei `a === "x" ? "eins" : "zwei"` zaehlen nur die Zweige, nicht die Bedingung.
const ausdruck = treffer[1]!.includes("?")
? treffer[1]!.slice(treffer[1]!.indexOf("?"))
: treffer[1]!;
for (const [, literal] of ausdruck.matchAll(LITERAL)) gefunden.set(literal!, datei);
}
}
for (const [, literal] of inhalt.matchAll(/"([a-z_]+(?:\.[a-z_]+)+)"/g)) {
if (praefixe.has(literal!.split(".")[0]!) && !literal!.endsWith(".ts")) gefunden.set(literal!, datei);
}
}
return gefunden;
}
describe("Protokoll-Aktionen", () => {
const imCode = aktionenImCode();
it("findet die Aktionen im Quellcode", () => {
expect(imCode.size).toBeGreaterThan(40);
expect(imCode.has("authorized_keys.geschrieben")).toBe(true);
expect(imCode.has("hostkey.ersetzt")).toBe(true);
expect(imCode.has("privatekey.angezeigt")).toBe(true);
expect(imCode.has("schluessel.uebernommen")).toBe(true);
});
it.each([...imCode])("%s hat einen lesbaren Text (%s)", (aktion) => {
expect(AKTIONEN[aktion], `${aktion} fehlt in src/lib/protokoll-aktionen.ts`).toBeDefined();
});
it("jede Gruppe hat Aktionen", () => {
for (const gruppe of Object.keys(AKTIONSGRUPPEN) as (keyof typeof AKTIONSGRUPPEN)[]) {
expect(aktionenDerGruppe(gruppe).length, gruppe).toBeGreaterThan(0);
}
});
it("faellt fuer unbekannte Aktionen auf den technischen Namen zurueck", () => {
expect(aktionText("hostkey.ersetzt")).toBe("Host-Key ersetzt");
expect(aktionText("gibt.es.nicht")).toBe("gibt.es.nicht");
});
it("faerbt nach Ergebnis und Art", () => {
expect(aktionTon("authorized_keys.geschrieben", "ERFOLG")).toBe("ok");
expect(aktionTon("anmeldung.oidc", "ERFOLG")).toBe("neutral");
expect(aktionTon("anmeldung.verweigert", "VERWEIGERT")).toBe("gefahr");
expect(aktionTon("verbindungstest", "FEHLER")).toBe("gefahr");
});
});
+349
View File
@@ -0,0 +1,349 @@
import { createSocket } from "node:dgram";
import type { AddressInfo } from "node:net";
import { readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@/lib/db";
import { LEERER_FILTER, protokollFilterAus, type ProtokollFilter } from "@/lib/protokoll-filter";
import {
protokollAkteure,
protokollSeite,
protokollStapelweise,
protokollZaehlen,
SEITENGROESSE,
} from "@/server/queries/protokoll";
import {
kettePruefenZwischengespeichert,
ketteZwischengespeichert,
ketteZwischenspeicherLeeren,
protokollieren,
syslogKonfig,
syslogZuruecksetzen,
} from "@/server/services/protokoll";
import { datenbankLeeren } from "./hilfen/db";
/**
* Protokollansicht (#027) gegen die Testdatenbank: Filter, Cursor, CSV-Route, Kettenpruefung
* und Syslog-Weiterleitung. Die Sitzung der Route wird nachgebildet – geprueft wird, dass der
* Handler das Recht selbst verlangt.
*/
const recht = vi.hoisted(() => ({ erlaubt: true }));
vi.mock("@/server/auth/sitzungspflicht", async (original) => {
const echt = await original<typeof import("@/server/auth/sitzungspflicht")>();
return {
...echt,
rechtErforderlich: vi.fn(async () => {
if (!recht.erlaubt) throw new echt.RechteFehler("Nicht angemeldet.");
return { benutzer: { id: "b-leser", name: "Lea Leser", rolle: "LESER" } };
}),
};
});
type Roh = {
aktion: string;
akteurName?: string;
ergebnis?: "ERFOLG" | "FEHLER" | "VERWEIGERT";
hostName?: string | null;
zielbenutzer?: string | null;
fingerprint?: string | null;
zeitpunkt?: Date;
diff?: string;
};
async function eintragen(daten: Roh) {
return prisma.protokoll.create({
data: {
akteurName: daten.akteurName ?? "Florian",
aktion: daten.aktion,
ergebnis: daten.ergebnis ?? "ERFOLG",
hostName: daten.hostName ?? null,
zielbenutzer: daten.zielbenutzer ?? null,
fingerprint: daten.fingerprint ?? null,
diff: daten.diff ?? null,
...(daten.zeitpunkt ? { zeitpunkt: daten.zeitpunkt } : {}),
},
});
}
const filter = (teil: Partial<ProtokollFilter>): ProtokollFilter => ({ ...LEERER_FILTER, ...teil });
beforeEach(async () => {
recht.erlaubt = true;
ketteZwischenspeicherLeeren();
await datenbankLeeren();
});
describe("protokollSeite", () => {
it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => {
// IDs sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – nur die Ordnung zaehlt.
const ids: string[] = [];
for (let i = 0; i < SEITENGROESSE + 7; i += 1)
ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id));
const erste = await protokollSeite(LEERER_FILTER);
expect(erste.zeilen.map((z) => z.id)).toEqual(ids.slice(0, SEITENGROESSE));
expect(erste.weiterVor).toBe(ids[SEITENGROESSE - 1]);
// Ein neuer Eintrag zwischen zwei Seiten verschiebt den Cursor nicht (anders als OFFSET).
await eintragen({ aktion: "host.geaendert" });
const zweite = await protokollSeite(LEERER_FILTER, BigInt(erste.weiterVor!));
expect(zweite.zeilen.map((z) => z.id)).toEqual(ids.slice(SEITENGROESSE));
expect(zweite.weiterVor).toBeNull();
});
it("liefert Hash, Vorgaenger-Hash und Details als Liste", async () => {
await protokollieren({
akteur: "System",
aktion: "abgleich",
ergebnis: "ERFOLG",
details: { befunde: 2, benutzer: ["root"], hinweis: "ok" },
});
const [zeile] = (await protokollSeite(LEERER_FILTER)).zeilen;
expect(zeile!.hash).toMatch(/^[0-9a-f]{64}$/);
expect(zeile!.vorgaengerHash).toBe("");
// jsonb ordnet die Schluessel selbst; die Reihenfolge ist daher nicht Teil der Zusage.
expect(zeile!.details).toHaveLength(3);
expect(zeile!.details).toEqual(
expect.arrayContaining([
{ name: "befunde", wert: "2" },
{ name: "benutzer", wert: '[\n "root"\n]' },
{ name: "hinweis", wert: "ok" },
]),
);
});
it("filtert nach Aktionsgruppe, Ergebnis, Akteur, Host und Freitext", async () => {
await eintragen({ aktion: "anmeldung.oidc", akteurName: "Anna" });
await eintragen({ aktion: "anmeldung.verweigert", akteurName: "Mallory", ergebnis: "VERWEIGERT" });
await eintragen({
aktion: "authorized_keys.geschrieben",
hostName: "srv-pve01",
zielbenutzer: "backup",
fingerprint: "SHA256:abcdef",
});
await eintragen({ aktion: "hostkey.ersetzt", hostName: "srv-db02" });
const aktionen = async (f: Partial<ProtokollFilter>) =>
(await protokollSeite(filter(f))).zeilen.map((z) => z.aktion);
expect(await aktionen({ gruppe: "anmeldung" })).toEqual(["anmeldung.verweigert", "anmeldung.oidc"]);
expect(await aktionen({ gruppe: "hostkey" })).toEqual(["hostkey.ersetzt"]);
expect(await aktionen({ ergebnis: "VERWEIGERT" })).toEqual(["anmeldung.verweigert"]);
expect(await aktionen({ akteur: "Anna" })).toEqual(["anmeldung.oidc"]);
expect(await aktionen({ host: "PVE" })).toEqual(["authorized_keys.geschrieben"]);
expect(await aktionen({ q: "abcdef" })).toEqual(["authorized_keys.geschrieben"]);
expect(await aktionen({ q: "BACKUP" })).toEqual(["authorized_keys.geschrieben"]);
expect(await aktionen({ q: "db02" })).toEqual(["hostkey.ersetzt"]);
expect(await aktionen({ gruppe: "ausrollen", host: "db02" })).toEqual([]);
expect(await protokollZaehlen(filter({ gruppe: "anmeldung" }))).toEqual({ gesamt: 4, treffer: 2 });
expect(await protokollAkteure()).toEqual(["Anna", "Florian", "Mallory"]);
});
it("filtert den Zeitraum nach Berliner Kalendertagen", async () => {
// 11.10. 23:30 Berlin = 21:30 UTC; 12.10. 00:30 Berlin = 22:30 UTC am 11.10.
await eintragen({ aktion: "host.angelegt", zeitpunkt: new Date("2026-10-11T21:30:00Z") });
await eintragen({ aktion: "host.geaendert", zeitpunkt: new Date("2026-10-11T22:30:00Z") });
await eintragen({ aktion: "host.archiviert", zeitpunkt: new Date("2026-10-12T21:59:00Z") });
await eintragen({ aktion: "kunde.angelegt", zeitpunkt: new Date("2026-10-12T22:01:00Z") });
const aktionen = async (f: Partial<ProtokollFilter>) =>
(await protokollSeite(filter(f))).zeilen.map((z) => z.aktion);
expect(await aktionen({ von: "2026-10-12", bis: "2026-10-12" })).toEqual([
"host.archiviert",
"host.geaendert",
]);
expect(await aktionen({ bis: "2026-10-11" })).toEqual(["host.angelegt"]);
expect(await aktionen({ von: "2026-10-13" })).toEqual(["kunde.angelegt"]);
});
it("ignoriert unbekannte Filterwerte", () => {
expect(
protokollFilterAus({
gruppe: "drop table",
ergebnis: "VIELLEICHT",
von: "12.10.2026",
bis: "2026-13-45",
}),
).toEqual(LEERER_FILTER);
});
});
describe("protokollStapelweise", () => {
it("liest alle Treffer in Stapeln", async () => {
const erwartet: bigint[] = [];
for (let i = 0; i < 12; i += 1) {
const zeile = await eintragen({ aktion: i % 2 ? "host.angelegt" : "anmeldung.oidc" });
if (i % 2) erwartet.unshift(zeile.id);
}
const groessen: number[] = [];
const ids: bigint[] = [];
for await (const stapel of protokollStapelweise(filter({ gruppe: "hosts" }), 4)) {
groessen.push(stapel.length);
ids.push(...stapel.map((z) => z.id));
}
expect(groessen).toEqual([4, 2]);
expect(ids).toEqual(erwartet);
});
});
describe("GET /api/protokoll.csv", () => {
async function abrufen(query = "") {
const { GET } = await import("@/app/api/protokoll.csv/route");
return GET(new Request(`http://localhost/api/protokoll.csv${query}`));
}
it("verweigert ohne Recht und protokolliert dann nichts", async () => {
recht.erlaubt = false;
const antwort = await abrufen();
expect(antwort.status).toBe(403);
expect(await prisma.protokoll.count()).toBe(0);
});
it("liefert Kopfzeile, Zeilen und entschaerft Formeln", async () => {
await eintragen({
aktion: "authorized_keys.geschrieben",
akteurName: "=HYPERLINK(1)",
hostName: "srv-pve01",
zielbenutzer: "root",
fingerprint: "SHA256:abc",
diff: '+ ssh-ed25519 AAAA "x"\n- ssh-rsa BBBB',
});
const antwort = await abrufen();
expect(antwort.status).toBe(200);
expect(antwort.headers.get("content-type")).toContain("text/csv");
expect(antwort.headers.get("content-disposition")).toMatch(/sshkv-protokoll-\d{4}-\d{2}-\d{2}\.csv/);
const text = (await antwort.text()).replace(/^/, "");
const [kopf] = text.split("\r\n");
expect(kopf).toBe(
'"id";"zeitpunkt";"akteur";"aktion";"ergebnis";"host";"zielbenutzer";"fingerprint";"diff";"details";"vorgaengerHash";"hash"',
);
expect(text).toContain(`"'=HYPERLINK(1)";"authorized_keys.geschrieben";"ERFOLG";"srv-pve01";"root"`);
// Mehrzeiliger Diff bleibt eine Zelle; das fuehrende „+“ wird wie eine Formel entschaerft.
expect(text).toContain(`"'+ ssh-ed25519 AAAA ""x""\n- ssh-rsa BBBB"`);
expect(text).toMatch(/\r\n"\d+";"\d{4}-\d{2}-\d{2}T[\d:.]+Z";"'=HYPERLINK/);
});
it("protokolliert den Export mit den Filtern", async () => {
await (await abrufen("?gruppe=hostkey&q=srv")).text();
const [eintrag] = await prisma.protokoll.findMany({ where: { aktion: "protokoll.exportiert" } });
expect(eintrag).toMatchObject({ akteurName: "Lea Leser", akteurId: "b-leser", ergebnis: "ERFOLG" });
expect(eintrag!.details).toEqual({ filter: { gruppe: "hostkey", q: "srv" } });
});
it("wendet die Filter der Seite an", async () => {
await eintragen({ aktion: "hostkey.ersetzt", hostName: "srv-a" });
await eintragen({ aktion: "anmeldung.oidc" });
const text = await (await abrufen("?gruppe=hostkey")).text();
expect(text).toContain("hostkey.ersetzt");
expect(text).not.toContain("anmeldung.oidc");
});
it("streamt grosse Protokolle ueber mehrere Stapel", async () => {
await prisma.protokoll.createMany({
data: Array.from({ length: 1203 }, (_, i) => ({
akteurName: "System",
aktion: "abgleich",
ergebnis: "ERFOLG" as const,
hostName: `h${i}`,
})),
});
const text = await (await abrufen("?gruppe=abgleich")).text();
expect(text.trimEnd().split("\r\n")).toHaveLength(1 + 1203);
});
});
describe("Kettenpruefung", () => {
it("prueft auf Anfrage und haelt das Ergebnis 5 Minuten", async () => {
await eintragen({ aktion: "host.angelegt" });
await eintragen({ aktion: "host.geaendert" });
expect(ketteZwischengespeichert()).toBeNull();
const ergebnis = await kettePruefenZwischengespeichert("System");
expect(ergebnis).toMatchObject({ fehlerhafteId: null, eintraege: 2 });
expect(ketteZwischengespeichert()).toBe(ergebnis);
expect(ketteZwischengespeichert(new Date(Date.now() + 6 * 60 * 1000))).toBeNull();
});
it("meldet die erste fehlerhafte ID und protokolliert den Fund", async () => {
await eintragen({ aktion: "host.angelegt" });
const zweiter = await eintragen({ aktion: "host.geaendert" });
await eintragen({ aktion: "host.archiviert" });
await prisma.$transaction([
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" DISABLE TRIGGER "protokoll_kein_update"`),
prisma.$executeRawUnsafe(`UPDATE "Protokoll" SET "aktion" = 'gefaelscht' WHERE "id" = ${zweiter.id}`),
prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" ENABLE TRIGGER "protokoll_kein_update"`),
]);
const ergebnis = await kettePruefenZwischengespeichert({ id: "b1", name: "Florian" });
expect(ergebnis.fehlerhafteId).toBe(String(zweiter.id));
expect(await prisma.protokoll.count({ where: { aktion: "protokoll.kette_beschaedigt" } })).toBe(1);
});
});
describe("Syslog-Weiterleitung in protokollieren()", () => {
afterEach(() => {
delete process.env["SSHKV_SYSLOG_HOST"];
delete process.env["SSHKV_SYSLOG_PORT"];
syslogZuruecksetzen();
});
it("ist ohne Umgebung aus", () => {
syslogZuruecksetzen();
expect(syslogKonfig()).toBeNull();
});
it("sendet nach dem Schreiben – ohne Details und Diff", async () => {
const empfaenger = createSocket("udp4");
const eingang = new Promise<string>((fertig) =>
empfaenger.once("message", (d) => fertig(d.toString("utf8"))),
);
await new Promise<void>((fertig) => empfaenger.bind(0, "127.0.0.1", fertig));
process.env["SSHKV_SYSLOG_HOST"] = "127.0.0.1";
process.env["SSHKV_SYSLOG_PORT"] = String((empfaenger.address() as AddressInfo).port);
syslogZuruecksetzen();
try {
await protokollieren({
akteur: { id: "b1", name: "Florian" },
aktion: "privatekey.angezeigt",
ergebnis: "ERFOLG",
fingerprint: "SHA256:xyz",
diff: "+ geheimer-diff",
details: { pfad: "/geheim/pfad", passwort: "x" },
});
const zeile = await eingang;
const [eintrag] = await prisma.protokoll.findMany();
expect(zeile).toMatch(/^<85>1 /);
expect(zeile).toContain(`hash="${eintrag!.hash}"`);
expect(zeile).toContain(`id="${eintrag!.id}"`);
expect(zeile).toContain('fingerprint="SHA256:xyz"');
expect(zeile).not.toContain("geheimer-diff");
expect(zeile).not.toContain("/geheim/pfad");
} finally {
empfaenger.close();
}
});
it("schreibt auch, wenn der Syslog-Server nicht erreichbar ist", async () => {
process.env["SSHKV_SYSLOG_HOST"] = "nicht-aufloesbar.invalid";
syslogZuruecksetzen();
const fehler = vi.spyOn(console, "error").mockImplementation(() => {});
await protokollieren({ akteur: "System", aktion: "abgleich", ergebnis: "ERFOLG" });
expect(await prisma.protokoll.count()).toBe(1);
fehler.mockRestore();
});
});
describe("Protokoll ist ueber die Anwendung nicht aenderbar", () => {
function dateien(verzeichnis: string): string[] {
return readdirSync(verzeichnis).flatMap((name) => {
const pfad = path.join(verzeichnis, name);
if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad);
return /\.(ts|tsx|sql)$/.test(name) ? [pfad] : [];
});
}
it.each(dateien("src"))("%s aendert und loescht keine Protokolleintraege", (datei) => {
const inhalt = readFileSync(datei, "utf8");
expect(inhalt).not.toMatch(/\.protokoll\s*\.\s*(update|updateMany|upsert|delete|deleteMany)\b/);
expect(inhalt).not.toMatch(/(UPDATE|DELETE\s+FROM|TRUNCATE)\s+"?Protokoll"?/i);
expect(inhalt).not.toMatch(/DISABLE\s+TRIGGER/i);
});
});
+219
View File
@@ -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();
});
});